fix snmp链路测试

This commit is contained in:
zxr
2026-07-05 21:21:53 +08:00
parent 1ad0323715
commit cc6ea1acc6
11 changed files with 223 additions and 23 deletions

View File

@@ -1,5 +1,9 @@
# Architect Mode Rules (Non-Obvious Only)
## Development Constraints
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
## Architecture Overview
- Vue 3 SPA with dynamic route loading from server

View File

@@ -1,5 +1,9 @@
# Code Mode Rules (Non-Obvious Only)
## Development Constraints
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
## API Layer
- Two axios instances exist: [`request.ts`](src/api/request.ts) (custom with workspace header) and [`interceptor.ts`](src/api/interceptor.ts) (global with Bearer token). Choose based on whether you need workspace support.

View File

@@ -13,6 +13,10 @@ This file provides guidance to agents when working with code in this repository.
## Critical Architecture Notes
### Development Constraints
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
### Vite Config Location
Config files are in `config/` directory, NOT root. All vite commands reference `./config/vite.config.*.ts`.

View File

@@ -1,5 +1,5 @@
allowBuilds:
esbuild: set this to true or false
less: set this to true or false
unrs-resolver: set this to true or false
vue-demi: set this to true or false
esbuild: true
less: true
unrs-resolver: true
vue-demi: true

View File

@@ -48,6 +48,7 @@ import {
setMessageStatus,
} from '@/api/message'
import useLoading from '@/hooks/loading'
import useNotificationSocket from '@/hooks/useNotificationSocket'
import { Message } from '@arco-design/web-vue'
import { computed, reactive, ref, toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -111,6 +112,9 @@ async function fetchSourceData() {
setLoading(false)
}
}
const upsertMessageToTop = (message: MessageRecord) => {
messageData.messageList = [message, ...messageData.messageList.filter((item) => item.id !== message.id)]
}
async function readMessage(data: MessageListType) {
const ids = data.map((item) => item.id)
await setMessageStatus({ ids })
@@ -190,6 +194,10 @@ const emptyList = () => {
messageData.messageList = []
}
fetchSourceData()
useNotificationSocket<MessageRecord>({
onNotification: upsertMessageToTop,
onReconnectAfter: fetchSourceData,
})
</script>
<style scoped lang="less">

View File

@@ -49,10 +49,10 @@
</a-button>
</a-tooltip>
</li>
<!-- <li>
<li>
<a-tooltip :content="$t('settings.navbar.alerts')">
<div class="message-box-trigger">
<a-badge :count="9" dot>
<a-badge dot>
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="setPopoverVisible">
<icon-notification />
</a-button>
@@ -70,7 +70,7 @@
<message-box />
</template>
</a-popover>
</li> -->
</li>
<li>
<a-tooltip :content="isFullscreen ? t('settings.navbar.screen.toExit') : t('settings.navbar.screen.toFull')">
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="toggleFullScreen">

View File

@@ -0,0 +1,126 @@
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
import { onBeforeUnmount, onMounted } from 'vue'
interface NotificationSocketMessage<T> {
type: string
data?: T
}
interface UseNotificationSocketOptions<T> {
onNotification: (data: T) => void
onReconnectAfter?: () => void | Promise<void>
}
const MAX_RECONNECT_ATTEMPTS = 8
const MAX_RECONNECT_DELAY = 30_000
const RECONNECT_STEP_DELAY = 1_000
function buildNotificationSocketUrl() {
const token = SafeStorage.get<string>(AppStorageKey.TOKEN)
if (!token) return null
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
const url = new URL(baseUrl, window.location.origin)
const basePath = url.pathname.replace(/\/$/, '')
url.pathname = `${basePath}/Alert/v1/message/ws`
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
// WebSocket 无法直接设置 Authorization header如后端仍依赖 header 认证,
// 需要后端适配 query token 或签发一次性 ws ticket。
url.searchParams.set('token', token)
return url.toString()
}
export default function useNotificationSocket<T>(options: UseNotificationSocketOptions<T>) {
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempt = 0
let isManualClose = false
let isReconnecting = false
const clearReconnectTimer = () => {
if (!reconnectTimer) return
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const closeSocket = () => {
if (!socket) return
socket.onopen = null
socket.onmessage = null
socket.onerror = null
socket.onclose = null
socket.close()
socket = null
}
const scheduleReconnect = () => {
if (isManualClose || reconnectTimer || reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) return
reconnectAttempt += 1
isReconnecting = true
const delay = Math.min(reconnectAttempt * RECONNECT_STEP_DELAY, MAX_RECONNECT_DELAY)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (isManualClose) return
connect()
}, delay)
}
const handleMessage = (event: MessageEvent<string>) => {
try {
const message = JSON.parse(event.data) as NotificationSocketMessage<T>
if (message.type === 'notification' && message.data) {
options.onNotification(message.data)
}
} catch (error) {
// 忽略无法识别的消息,避免单条异常数据中断后续通知接收。
}
}
function connect() {
if (isManualClose) return
const socketUrl = buildNotificationSocketUrl()
if (!socketUrl) return
closeSocket()
socket = new WebSocket(socketUrl)
socket.onopen = () => {
reconnectAttempt = 0
if (isReconnecting) {
isReconnecting = false
options.onReconnectAfter?.()
}
}
socket.onmessage = handleMessage
socket.onerror = () => {
socket?.close()
}
socket.onclose = () => {
socket = null
scheduleReconnect()
}
}
const cleanup = () => {
isManualClose = true
clearReconnectTimer()
closeSocket()
}
onMounted(() => {
isManualClose = false
connect()
})
onBeforeUnmount(cleanup)
return {
cleanup,
}
}

View File

@@ -9,9 +9,9 @@
@cancel="handleCancel"
@update:visible="handleUpdateVisible"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" auto-label-width>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" auto-label-width>
<a-alert type="info" show-icon class="mb-12">
采集协议固定为 SNMP + SSH 双协议请同时完成两部分配置编辑时密码不回显留空表示不修改
采集协议固定为 SNMP仅在勾选采集FIB信息时需要填写 SSH编辑时密码不回显留空表示不修改
</a-alert>
<a-divider orientation="left">基础信息</a-divider>
<a-row :gutter="20">
@@ -75,7 +75,7 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="采集协议"><a-tag color="purple">SNMP + SSH固定</a-tag></a-form-item>
<a-form-item label="采集协议"><a-tag color="purple">SNMP + SSHFIB可选</a-tag></a-form-item>
</a-col>
</a-row>
@@ -152,20 +152,24 @@
</a-col>
</a-row>
<a-divider orientation="left">SSH 配置必填</a-divider>
<a-divider orientation="left">
{{ fibEnabled ? 'SSH 配置(采集 FIB 时必填)' : 'SSH 配置(未采集 FIB 时可选)' }}
</a-divider>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ssh_port" label="SSH端口" required>
<a-form-item field="ssh_port" label="SSH端口" :required="fibEnabled">
<a-input-number v-model="formData.ssh.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="ssh_username" label="SSH用户名" required><a-input v-model="formData.ssh.username" /></a-form-item>
<a-form-item field="ssh_username" label="SSH用户名" :required="fibEnabled">
<a-input v-model="formData.ssh.username" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ssh_password" label="SSH密码" required>
<a-form-item field="ssh_password" label="SSH密码" :required="fibEnabled">
<a-input-password v-model="formData.ssh.password" :placeholder="isEdit ? '留空表示不修改' : '请输入 SSH 密码'" />
</a-form-item>
</a-col>
@@ -259,12 +263,23 @@ const defaults = () => ({
collect: { interface_enabled: true, arp_enabled: true, route_enabled: true, fib_enabled: true, timeout_sec: 5, retries: 1 },
})
const formData = reactive(defaults())
const fibEnabled = computed(() => formData.collect.fib_enabled)
const rules = {
name: [{ required: true, message: '请输入设备名称' }],
category: [{ required: true, message: '请选择设备类型' }],
host: [{ required: true, message: '请输入设备地址' }],
community: [{ required: true, message: '请输入 SNMP community' }],
ssh_username: [{ required: true, message: '请输入 SSH 用户名' }],
ssh_username: [
{
validator: (value: string | undefined, callback: (error?: string) => void) => {
if (formData.collect.fib_enabled && !String(value || '').trim()) {
callback('请输入 SSH 用户名')
return
}
callback()
},
},
],
}
watch(
@@ -322,6 +337,15 @@ watch(tagList, (tags) => {
formData.tags = tags.join(',')
})
watch(
() => formData.collect.fib_enabled,
(enabled) => {
if (!enabled) {
;(formRef.value as any)?.clearValidate?.(['ssh_port', 'ssh_username', 'ssh_password'])
}
}
)
const handleOk = async () => {
try {
await formRef.value?.validate()
@@ -343,13 +367,15 @@ const handleOk = async () => {
return
}
}
if (!formData.ssh.username.trim()) {
Message.warning('请填写 SSH 用户名')
return
}
if (!isEdit.value && !formData.ssh.password.trim()) {
Message.warning('请填写 SSH 密码')
return
if (formData.collect.fib_enabled) {
if (!formData.ssh.username.trim()) {
Message.warning('请填写 SSH 用户名')
return
}
if (!isEdit.value && !formData.ssh.password.trim() && !formData.ssh.private_key.trim()) {
Message.warning('请填写 SSH 密码或 SSH 私钥')
return
}
}
confirmLoading.value = true
const payload: any = {

View File

@@ -45,6 +45,12 @@ export const columns = [
title: '类型',
width: 100,
},
{
dataIndex: 'asset_id',
title: '关联资产',
width: 100,
slotName: 'asset_id',
},
{
dataIndex: 'location',
title: '位置',

View File

@@ -30,6 +30,13 @@
{{ record.id }}
</template>
<!-- 关联资产 -->
<template #asset_id="{ record }">
<a-tag :color="record.asset_id ? 'green' : 'gray'">
{{ record.asset_id ? `已关联 #${record.asset_id}` : '未关联' }}
</a-tag>
</template>
<!-- 远程访问 -->
<template #remote_access="{ record }">
<a-tag :color="record.remote_access ? 'green' : 'gray'">

View File

@@ -60,6 +60,16 @@
</a-col>
</a-row>
<a-alert
v-if="showVirtualEmptyTip"
class="empty-tip"
type="info"
show-icon
banner
>
当前暂无已关联资产的虚拟服务器请在服务器管理中将虚拟服务器关联到资产宿主机趋势还需要配置已关联资产的物理服务器
</a-alert>
<!-- 图表区域 -->
<a-row :gutter="16" class="chart-row">
<a-col :xs="24" :lg="8">
@@ -188,7 +198,7 @@ const stats = ref({
memoryUsage: 0,
})
const loading = ref(false)
const loading = ref(true)
// 宿主机状态
const hostStatus = ref<
@@ -317,6 +327,7 @@ const cpuPhysicalText = computed(() => formatMaxTwoDecimals(cpuPhysical.value))
const memoryTotalText = computed(() => formatMaxTwoDecimals(memVirtualGb.value + memPhysicalGb.value))
const memoryVirtualText = computed(() => formatMaxTwoDecimals(memVirtualGb.value))
const memoryPhysicalText = computed(() => formatMaxTwoDecimals(memPhysicalGb.value))
const showVirtualEmptyTip = computed(() => !loading.value && stats.value.total === 0)
const toGb = (bytes: number) => bytes / 1024 / 1024 / 1024
const safeNum = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
const hourLabel = (v: string) => {
@@ -410,6 +421,10 @@ export default {
margin-bottom: 16px;
}
.empty-tip {
margin-bottom: 16px;
}
.stats-card {
height: 100%;