fix snmp链路测试
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
126
src/hooks/useNotificationSocket.ts
Normal file
126
src/hooks/useNotificationSocket.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 + SSH(FIB可选)</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 = {
|
||||
|
||||
@@ -45,6 +45,12 @@ export const columns = [
|
||||
title: '类型',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
dataIndex: 'asset_id',
|
||||
title: '关联资产',
|
||||
width: 100,
|
||||
slotName: 'asset_id',
|
||||
},
|
||||
{
|
||||
dataIndex: 'location',
|
||||
title: '位置',
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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%;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user