This commit is contained in:
zxr
2026-04-28 17:22:06 +08:00
parent 39150be726
commit f7b34c5d60
24 changed files with 837 additions and 1915 deletions

View File

@@ -0,0 +1,94 @@
import { request } from '@/api/request'
export type NetworkDeviceProtocol = 'snmp' | 'ssh' | 'telnet'
export type NetworkDeviceSnmpVersion = 'v1' | 'v2c' | 'v3'
export type NetworkDeviceStatus = 'online' | 'offline' | 'error' | 'unknown'
export interface NetworkDeviceService {
id: number
created_at: string
updated_at: string
deleted_at: string | null
service_identity: string
server_identity: string
name: string
category: string
type: string
host: string
port: number
protocol: NetworkDeviceProtocol
community: string
snmp_version: NetworkDeviceSnmpVersion
username: string
vendor: string
model: string
location: string
description: string
enabled: boolean
interval: number
extra: string
tags: string
status: NetworkDeviceStatus
status_code: number
status_message: string
response_time: number
last_check_time: string
last_online_time: string | null
last_offline_time: string | null
continuous_errors: number
uptime: number
}
export interface NetworkDeviceListResponse {
total: number
page: number
page_size: number
data: NetworkDeviceService[]
}
export interface NetworkDeviceQueryParams {
page?: number
size?: number
keyword?: string
enabled?: boolean
}
export interface CreateNetworkDeviceParams {
service_identity?: string
server_identity?: string
name: string
category: string
type?: string
host: string
port?: number
protocol?: NetworkDeviceProtocol
community?: string
snmp_version?: NetworkDeviceSnmpVersion
username?: string
password?: string
vendor?: string
model?: string
location?: string
description?: string
enabled?: boolean
interval?: number
extra?: string
tags?: string
}
export type UpdateNetworkDeviceParams = Partial<CreateNetworkDeviceParams>
export const fetchNetworkDeviceList = (params: NetworkDeviceQueryParams) =>
request.get<NetworkDeviceListResponse>('/DC-Control/v1/network-device-services', { params })
export const fetchNetworkDeviceDetail = (id: number) =>
request.get<NetworkDeviceService>(`/DC-Control/v1/network-device-services/${id}`)
export const createNetworkDevice = (data: CreateNetworkDeviceParams) =>
request.post<{ message: string; id: number }>('/DC-Control/v1/network-device-services', data)
export const updateNetworkDevice = (id: number, data: UpdateNetworkDeviceParams) =>
request.put<{ message: string }>(`/DC-Control/v1/network-device-services/${id}`, data)
export const deleteNetworkDevice = (id: number) =>
request.delete<{ message: string }>(`/DC-Control/v1/network-device-services/${id}`)

View File

@@ -0,0 +1,29 @@
import { request } from '@/api/request'
export const fetchNetworkScreenOverview = () =>
request.get('/DC-Control/v1/network-screen/overview')
export const fetchNetworkScreenHealthMatrix = (params?: { window?: string }) =>
request.get('/DC-Control/v1/network-screen/health-matrix', { params })
export const fetchNetworkScreenFreshnessDistribution = () =>
request.get('/DC-Control/v1/network-screen/freshness-distribution')
export const fetchNetworkScreenProtocolDistribution = () =>
request.get('/DC-Control/v1/network-screen/protocol-distribution')
export const fetchNetworkScreenResourceOverview = (params?: { window?: string }) =>
request.get('/DC-Control/v1/network-screen/resource-overview', { params })
export const fetchNetworkScreenAlertsStream = (params?: { limit?: number; window?: string }) =>
request.get('/DC-Control/v1/network-screen/alerts-stream', { params })
export const fetchNetworkScreenDevices = (params?: {
page?: number
size?: number
keyword?: string
status?: string
enabled?: boolean
protocol?: string
vendor?: string
}) => request.get('/DC-Control/v1/network-screen/devices', { params })

View File

@@ -154,8 +154,8 @@ export const localMenuFlatItems: MenuItem[] = [
{
id: 22,
identity: '019b591d-0159-7e46-a5e0-fb69c6b62a25',
title: '网络设备采集管理',
title_en: 'Network Device Collection Management',
title: '网络设备数据采集管理',
title_en: 'Network Device Data Collection Management',
code: 'ops:集群采集控制中心:网络设备采集管理',
description: '集群采集控制中心 - 网络设备采集管理',
app_id: 2,

View File

@@ -164,8 +164,8 @@ export const localMenuItems: MenuItem[] = [
{
id: 22,
identity: '019b591d-0159-7e46-a5e0-fb69c6b62a25',
title: '网络设备采集管理',
title_en: 'Network Device Collection Management',
title: '网络设备数据采集管理',
title_en: 'Network Device Data Collection Management',
code: 'ops:集群采集控制中心:网络设备采集管理',
description: '集群采集控制中心 - 网络设备采集管理',
app_id: 2,

View File

@@ -177,7 +177,10 @@ const getQuietHoursDisplay = () => {
const formatTime = (time?: string) => {
if (!time) return ''
try {
if (time.startsWith('0001-01-01')) return ''
const date = new Date(time)
if (Number.isNaN(date.getTime())) return ''
if (date.getFullYear() <= 1970) return ''
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',

View File

@@ -154,7 +154,10 @@ const rules = {
// 格式化时间
const formatTime = (time: string) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -234,7 +234,10 @@ const formatUptime = (uptime?: number) => {
// 格式化时间
const formatTime = (time?: string | null) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -253,7 +253,10 @@ const formatUptime = (uptime: number) => {
// 格式化时间
const formatTime = (time: string) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -124,7 +124,10 @@ const handleViewMetrics = async () => {
const formatTime = (time?: string) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -530,7 +530,11 @@ const formatUptime = (uptime?: number) => {
// 格式化时间
const formatTime = (time?: string | null) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
return date.toLocaleString('zh-CN')
}
// 格式化额外配置

View File

@@ -241,7 +241,10 @@ const formatUptime = (uptime?: number) => {
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -1,705 +1,120 @@
<template>
<div class="server-detail">
<div class="detail-header">
<div class="server-info">
<h2>{{ record.name || '办公PC详情' }}</h2>
<div class="info-tags">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
<a-tag color="blue">{{ record.server_type || '未知类型' }}</a-tag>
<a-tag color="cyan">{{ record.os || '未知系统' }}</a-tag>
</div>
</div>
<div class="header-actions">
<a-button @click="handleRemoteControl">
<template #icon>
<icon-desktop />
</template>
远程登录
</a-button>
<a-button type="primary" @click="handleRestart">
<template #icon>
<icon-refresh />
</template>
重启
</a-button>
<div class="detail">
<div class="header">
<div>
<div class="title">{{ record.name || '-' }}</div>
<a-space>
<a-tag :color="statusColor">{{ statusText }}</a-tag>
<a-tag color="arcoblue">{{ formatDeviceCategory(record.category) }}</a-tag>
<a-tag>{{ (record.vendor || '-') + (record.model ? ` / ${record.model}` : '') }}</a-tag>
</a-space>
</div>
<a-space>
<a-button type="primary" @click="$emit('edit')">编辑</a-button>
<a-button status="danger" @click="$emit('delete')">删除</a-button>
</a-space>
</div>
<a-tabs v-model:active-tab="activeTab" class="detail-tabs">
<a-tab-pane key="overview" title="实例详情">
<a-descriptions :column="2" bordered class="info-descriptions">
<a-descriptions-item label="唯一标识">{{ record.unique_id || '-' }}</a-descriptions-item>
<a-descriptions-item label="办公PC名称">{{ record.name || '-' }}</a-descriptions-item>
<a-descriptions-item label="操作系统">{{ record.os || '-' }}</a-descriptions-item>
<a-descriptions-item label="位置信息">{{ record.location || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ record.ip || '-' }}</a-descriptions-item>
<a-descriptions-item label="远程端口">{{ record.remote_port || '-' }}</a-descriptions-item>
<a-descriptions-item label="Agent URL">{{ record.agent_url || '-' }}</a-descriptions-item>
<a-descriptions-item label="数据采集">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已开启' : '未开启' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.collection_interval ? `${record.collection_interval}分钟` : '-' }}</a-descriptions-item>
<a-descriptions-item label="备注信息" :span="2">{{ record.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="monitor" title="监控">
<div class="monitor-section">
<div class="time-selector">
<a-radio-group v-model="timeRange" type="button">
<a-radio value="1h">1小时</a-radio>
<a-radio value="3h">3小时</a-radio>
<a-radio value="6h">6小时</a-radio>
<a-radio value="12h">12小时</a-radio>
<a-radio value="1d">1</a-radio>
<a-radio value="3d">3</a-radio>
<a-radio value="7d">7</a-radio>
<a-radio value="14d">14</a-radio>
<a-radio value="custom">自定义</a-radio>
</a-radio-group>
</div>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">CPU使用率_宿主机视角 (%)</span>
<div class="chart-actions">
<a-select v-model="cpuMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="cpuOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>(ECS)CPU使用率_平均值</span>
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)CPU使用率_最小值</span>
<span class="legend-item"><span class="legend-dot purple"></span>(ECS)CPU使用率_最大值</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">总带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="bandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot green"></span>内网流入带宽</span>
<span class="legend-item"><span class="legend-dot blue"></span>内网流出带宽</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网带宽 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="publicBandwidthMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="publicBandwidthOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>公网流入带宽</span>
<span class="legend-item"><span class="legend-dot green"></span>公网流出带宽</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">公网流出带宽使用率 (%)</span>
<div class="chart-actions">
<a-select v-model="bandwidthUsageMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="bandwidthUsageOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)公网流出带宽使用率</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入BPS (bytes/s)</span>
<div class="chart-actions">
<a-select v-model="diskMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskBpsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘读取BPS</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘写入BPS</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">磁盘读取写入IOPS (Count/Second)</span>
<div class="chart-actions">
<a-select v-model="diskIopsMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="diskIopsOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>(ECS)所有磁盘每秒读取次数</span>
<span class="legend-item"><span class="legend-dot green"></span>(ECS)所有磁盘每秒写入次数</span>
</div>
</div>
</a-col>
</a-row>
<a-row :gutter="20" class="charts-row">
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">同时连接数 (Count)</span>
<div class="chart-actions">
<a-select v-model="connectionMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="connectionOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"><span class="legend-dot blue"></span>同时连接数</span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA流量使用信息 (bit/s)</span>
<div class="chart-actions">
<a-select v-model="rdmaMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
<a-col :span="8" class="chart-col">
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">RDMA资源使用信息 (Count)</span>
<div class="chart-actions">
<a-select v-model="rdmaResourceMetric" size="small" style="width: 120px">
<a-option value="average">平均值</a-option>
<a-option value="max">最大值</a-option>
</a-select>
<icon-settings />
</div>
</div>
<v-chart :option="rdmaResourceOption" class="chart" autoresize />
<div class="chart-legend">
<span class="legend-item"></span>
</div>
</div>
</a-col>
</a-row>
</div>
</a-tab-pane>
<a-tab-pane key="operation" title="操作记录">
<div class="operation-records">
<a-empty description="暂无操作记录" />
</div>
</a-tab-pane>
</a-tabs>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="服务标识">{{ record.service_identity || '-' }}</a-descriptions-item>
<a-descriptions-item label="执行节点">{{ record.server_identity || '-' }}</a-descriptions-item>
<a-descriptions-item label="采集地址">{{ record.host || '-' }}</a-descriptions-item>
<a-descriptions-item label="端口">{{ record.port || '-' }}</a-descriptions-item>
<a-descriptions-item label="协议">{{ String(record.protocol || '-').toUpperCase() }}</a-descriptions-item>
<a-descriptions-item label="SNMP版本">{{ String(record.snmp_version || '-').toUpperCase() }}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ record.username || '-' }}</a-descriptions-item>
<a-descriptions-item label="密码">已设置不回显</a-descriptions-item>
<a-descriptions-item label="采集开关">
<a-tag :color="record.enabled ? 'green' : 'gray'">{{ record.enabled ? '启用' : '停用' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.interval || 0 }} </a-descriptions-item>
<a-descriptions-item label="状态消息" :span="2">{{ record.status_message || '-' }}</a-descriptions-item>
<a-descriptions-item label="最近检查">{{ formatTime(record.last_check_time) }}</a-descriptions-item>
<a-descriptions-item label="响应时间">{{ formatResponseTime(record.response_time) }}</a-descriptions-item>
<a-descriptions-item label="最近上线">{{ formatTime(record.last_online_time) }}</a-descriptions-item>
<a-descriptions-item label="最近离线">{{ formatTime(record.last_offline_time) }}</a-descriptions-item>
<a-descriptions-item label="连续失败次数">{{ record.continuous_errors ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="运行时长">{{ formatUptime(record.uptime) }}</a-descriptions-item>
<a-descriptions-item label="标签" :span="2">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="位置">{{ record.location || '-' }}</a-descriptions-item>
<a-descriptions-item label="描述">{{ record.description || '-' }}</a-descriptions-item>
</a-descriptions>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconDesktop, IconRefresh, IconSettings } from '@arco-design/web-vue/es/icon'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
])
import { computed } from 'vue'
import type { NetworkDeviceService } from '@/api/ops/network-device'
interface Props {
record: any
record: Partial<NetworkDeviceService>
}
const props = defineProps<Props>()
defineEmits(['edit', 'delete'])
const emit = defineEmits(['remote-control', 'restart', 'close'])
const statusMap: Record<string, { text: string; color: string }> = {
online: { text: '在线', color: 'green' },
offline: { text: '离线', color: 'red' },
error: { text: '异常', color: 'orange' },
unknown: { text: '未知', color: 'gray' },
}
const activeTab = ref('overview')
const timeRange = ref('1h')
const cpuMetric = ref('average')
const bandwidthMetric = ref('average')
const publicBandwidthMetric = ref('average')
const bandwidthUsageMetric = ref('average')
const diskMetric = ref('average')
const diskIopsMetric = ref('average')
const connectionMetric = ref('average')
const rdmaMetric = ref('average')
const rdmaResourceMetric = ref('average')
const statusText = computed(() => statusMap[props.record.status || 'unknown']?.text || '未知')
const statusColor = computed(() => statusMap[props.record.status || 'unknown']?.color || 'gray')
const generateTimeData = (count: number) => {
const now = Date.now()
const data = []
for (let i = count - 1; i >= 0; i--) {
data.push(new Date(now - i * 60000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }))
const formatDeviceCategory = (category?: string) => {
const map: Record<string, string> = {
router: '路由器',
switch: '交换机',
firewall: '防火墙',
access_point: '接入点',
load_balancer: '负载均衡',
other: '其他',
}
return data
if (!category) return '-'
return map[category] || category
}
const generateRandomData = (count: number, min: number, max: number) => {
return Array.from({ length: count }, () => Math.floor(Math.random() * (max - min + 1)) + min)
const formatTime = (time?: string | null) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const d = new Date(time)
if (Number.isNaN(d.getTime())) return '-'
if (d.getFullYear() <= 1970) return '-'
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(
d.getHours()
).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
const timeData = generateTimeData(60)
const formatResponseTime = (v?: number) => (typeof v === 'number' && v >= 0 ? `${v.toFixed(2)} ms` : '-')
const baseOption = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#e5e6eb',
textStyle: {
color: '#1d2129',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timeData,
axisLabel: {
fontSize: 10,
},
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
},
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
],
}
const cpuOption = computed(() => ({
...baseOption,
series: [
{
name: '平均值',
type: 'line',
smooth: true,
data: generateRandomData(60, 20, 80),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(0, 180, 42, 0.3)' },
{ offset: 1, color: 'rgba(0, 180, 42, 0.05)' },
],
},
},
},
{
name: '最小值',
type: 'line',
smooth: true,
data: generateRandomData(60, 10, 40),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '最大值',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 95),
lineStyle: { color: '#722ed1' },
itemStyle: { color: '#722ed1' },
},
],
}))
const bandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '内网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 100000, 500000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
{
name: '内网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 300000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const publicBandwidthOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流入带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 50000, 200000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '公网流出带宽',
type: 'line',
smooth: true,
data: generateRandomData(60, 30000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const bandwidthUsageOption = computed(() => ({
...baseOption,
series: [
{
name: '公网流出带宽使用率',
type: 'line',
smooth: true,
data: generateRandomData(60, 5, 30),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(22, 93, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 93, 255, 0.05)' },
],
},
},
},
],
}))
const diskBpsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 10000, 100000),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入BPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 20000, 150000),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const diskIopsOption = computed(() => ({
...baseOption,
series: [
{
name: '磁盘读取IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 50, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
{
name: '磁盘写入IOPS',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 800),
lineStyle: { color: '#00b42a' },
itemStyle: { color: '#00b42a' },
},
],
}))
const connectionOption = computed(() => ({
...baseOption,
series: [
{
name: '同时连接数',
type: 'line',
smooth: true,
data: generateRandomData(60, 100, 500),
lineStyle: { color: '#165dff' },
itemStyle: { color: '#165dff' },
},
],
}))
const rdmaOption = computed(() => ({
...baseOption,
series: [],
}))
const rdmaResourceOption = computed(() => ({
...baseOption,
series: [],
}))
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
}
return textMap[status || ''] || '-'
}
const handleRemoteControl = () => {
emit('remote-control')
}
const handleRestart = () => {
Message.info('正在发送重启指令...')
emit('restart')
const formatUptime = (uptime?: number) => {
if (!uptime || uptime <= 0) return '-'
const days = Math.floor(uptime / 86400)
const hours = Math.floor((uptime % 86400) / 3600)
const minutes = Math.floor((uptime % 3600) / 60)
if (days > 0) return `${days}${hours}小时`
if (hours > 0) return `${hours}小时 ${minutes}分钟`
return `${minutes}分钟`
}
</script>
<style scoped lang="less">
.server-detail {
padding: 20px;
background: #fff;
border-radius: 4px;
.detail {
padding: 12px;
}
.detail-header {
.header {
display: flex;
align-items: center;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e6eb;
.server-info {
h2 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
.info-tags {
display: flex;
gap: 8px;
}
}
.header-actions {
display: flex;
gap: 12px;
}
margin-bottom: 16px;
}
.detail-tabs {
:deep(.arco-tabs-header) {
margin-bottom: 20px;
}
}
.info-descriptions {
:deep(.arco-descriptions-item-label) {
width: 140px;
background: #f7f8fa;
}
}
.monitor-section {
.time-selector {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-col {
margin-bottom: 20px;
}
.chart-card {
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
padding: 16px;
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.chart-title {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.chart-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.chart {
height: 200px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e6eb;
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #86909c;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.green {
background: #00b42a;
}
&.blue {
background: #165dff;
}
&.purple {
background: #722ed1;
}
}
}
}
}
}
.operation-records {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
</style>

View File

@@ -1,182 +1,254 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑网络设备' : '新增网络设备'"
:title="isEdit ? '编辑网络设备采集' : '新增网络设备采集'"
:confirm-loading="confirmLoading"
:mask-closable="false"
width="980px"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleUpdateVisible"
:confirm-loading="confirmLoading"
width="800px"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" auto-label-width>
<a-alert type="info" show-icon class="mb-12">
采集协议固定为 SNMP + SSH 双协议请同时完成两部分配置编辑时密码不回显留空表示不修改
</a-alert>
<a-divider orientation="left">基础信息</a-divider>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="name" label="设备名称">
<a-input v-model="formData.name" placeholder="请输入设备名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="type" label="设备类型">
<a-select v-model="formData.type" placeholder="请选择设备类型">
<a-option value="交换机">交换机</a-option>
<a-option value="路由器">路由器</a-option>
<a-option value="防火墙">防火墙</a-option>
<a-option value="负载均衡">负载均衡</a-option>
<a-option value="无线控制器">无线控制器</a-option>
<a-option value="其它">其它</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12"><a-form-item field="name" label="设备名称" required><a-input v-model="formData.name" placeholder="例如:核心路由器-01" /></a-form-item></a-col>
<a-col :span="12"><a-form-item field="category" label="设备类型" required><a-select v-model="formData.category" allow-search><a-option value="router">路由器</a-option><a-option value="switch">交换机</a-option><a-option value="firewall">防火墙</a-option><a-option value="access_point">接入点</a-option><a-option value="load_balancer">负载均衡</a-option><a-option value="other">其他</a-option></a-select></a-form-item></a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"><a-form-item field="server_identity" label="执行节点"><a-select v-model="formData.server_identity" allow-search allow-clear><a-option v-for="server in serverOptions" :key="server.server_identity" :value="server.server_identity">{{ server.name }} ({{ server.server_identity }})</a-option></a-select></a-form-item></a-col>
<a-col :span="12"><a-form-item field="vendor" label="厂商"><a-select v-model="formData.vendor" allow-search allow-create allow-clear><a-option value="Cisco">Cisco</a-option><a-option value="Huawei">Huawei</a-option><a-option value="H3C">H3C</a-option><a-option value="Juniper">Juniper</a-option></a-select></a-form-item></a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"><a-form-item field="model" label="型号"><a-input v-model="formData.model" placeholder="例如ASR1001-X" /></a-form-item></a-col>
<a-col :span="12"><a-form-item field="location" label="位置信息"><a-input v-model="formData.location" placeholder="例如A机房-R03-12U" /></a-form-item></a-col>
</a-row>
<a-form-item field="description" label="描述"><a-textarea v-model="formData.description" :rows="2" /></a-form-item>
<a-form-item field="tags" label="标签"><a-input-tag v-model="tagList" placeholder="回车添加标签" /></a-form-item>
<a-divider orientation="left">连接信息</a-divider>
<a-row :gutter="20">
<a-col :span="12"><a-form-item field="host" label="设备地址" required><a-input v-model="formData.host" placeholder="例如192.168.1.10" /></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-col>
</a-row>
<a-divider orientation="left">SNMP 配置必填</a-divider>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="manu" label="厂商">
<a-select v-model="formData.manu" placeholder="请选择厂商">
<a-option value="华为">华为</a-option>
<a-option value="华三">华三</a-option>
<a-option value="思科">思科</a-option>
<a-option value="深信服">深信服</a-option>
<a-option value="F5">F5</a-option>
<a-option value="锐捷">锐捷</a-option>
<a-option value="TP-LINK">TP-LINK</a-option>
<a-option value="其它">其它</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12"><a-form-item field="snmp_port" label="SNMP端口" required><a-input-number v-model="formData.port" :min="1" :max="65535" style="width: 100%" /></a-form-item></a-col>
<a-col :span="12"><a-form-item field="snmp_version" label="SNMP版本"><a-radio-group v-model="formData.snmp_version" type="button"><a-radio value="v1">v1</a-radio><a-radio value="v2c">v2c</a-radio><a-radio value="v3">v3</a-radio></a-radio-group></a-form-item></a-col>
</a-row>
<a-form-item v-if="formData.snmp_version !== 'v3'" field="community" label="Community" required><a-input v-model="formData.community" placeholder="SNMP v1/v2c 必填" /></a-form-item>
<a-row v-else :gutter="20">
<a-col :span="12"><a-form-item field="snmpv3_security_name" label="Security Name" required><a-input v-model="formData.snmpv3.security_name" /></a-form-item></a-col>
<a-col :span="12"><a-form-item field="snmpv3_security_level" label="Security Level"><a-select v-model="formData.snmpv3.security_level"><a-option value="noAuthNoPriv">noAuthNoPriv</a-option><a-option value="authNoPriv">authNoPriv</a-option><a-option value="authPriv">authPriv</a-option></a-select></a-form-item></a-col>
</a-row>
<a-divider orientation="left">SSH 配置必填</a-divider>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="model_num" label="型号">
<a-input v-model="formData.model_num" placeholder="请输入设备型号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="protocol" label="通讯协议">
<a-select v-model="formData.protocol" placeholder="请选择通讯协议">
<a-option value="SNMP">SNMP</a-option>
<a-option value="TELNET">TELNET</a-option>
<a-option value="SSH">SSH</a-option>
<a-option value="HTTP">HTTP</a-option>
<a-option value="HTTPS">HTTPS</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12"><a-form-item field="ssh_port" label="SSH端口" required><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-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="ip" label="IP 地址">
<a-input v-model="formData.ip" placeholder="可以输入多个 IP逗号做分隔" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="tags" label="标签">
<a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" />
</a-form-item>
</a-col>
<a-col :span="12"><a-form-item field="ssh_password" label="SSH密码" required><a-input-password v-model="formData.ssh.password" :placeholder="isEdit ? '留空表示不修改' : '请输入 SSH 密码'" /></a-form-item></a-col>
<a-col :span="12"><a-form-item field="ssh_command_fib" label="FIB命令覆盖"><a-input v-model="formData.ssh.command_fib" placeholder="可选:覆盖厂商默认命令" /></a-form-item></a-col>
</a-row>
<a-form-item field="ssh_private_key" label="SSH私钥"><a-textarea v-model="formData.ssh.private_key" :rows="2" placeholder="可选,支持粘贴私钥" /></a-form-item>
<a-form-item field="location" label="位置信息">
<a-input v-model="formData.location" placeholder="请输入位置信息" />
</a-form-item>
<a-form-item field="remark" label="备注信息">
<a-textarea v-model="formData.remark" placeholder="请输入备注信息" :rows="4" />
</a-form-item>
<a-divider orientation="left">采集策略</a-divider>
<a-row :gutter="20">
<a-col :span="12"><a-form-item field="enabled" label="启用采集"><a-switch v-model="formData.enabled" /></a-form-item></a-col>
<a-col :span="12"><a-form-item field="interval" label="采集间隔(秒)"><a-input-number v-model="formData.interval" :min="1" :max="86400" style="width: 100%" /></a-form-item></a-col>
</a-row>
<a-row :gutter="20"><a-col :span="12"><a-checkbox v-model="formData.collect.interface_enabled">采集接口信息</a-checkbox></a-col><a-col :span="12"><a-checkbox v-model="formData.collect.arp_enabled">采集ARP信息</a-checkbox></a-col></a-row>
<a-row :gutter="20" class="mt-8"><a-col :span="12"><a-checkbox v-model="formData.collect.route_enabled">采集路由信息</a-checkbox></a-col><a-col :span="12"><a-checkbox v-model="formData.collect.fib_enabled">采集FIB信息</a-checkbox></a-col></a-row>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import { fetchServerList, type ServerItem } from '@/api/ops/server'
import { createNetworkDevice, updateNetworkDevice } from '@/api/ops/network-device'
interface Props {
visible: boolean
record?: any
}
const props = withDefaults(defineProps<Props>(), {
record: () => ({}),
})
interface Props { visible: boolean; record?: any }
const props = withDefaults(defineProps<Props>(), { record: () => ({}) })
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref<FormInstance>()
const confirmLoading = ref(false)
const serverOptions = ref<ServerItem[]>([])
const tagList = ref<string[]>([])
const isEdit = computed(() => !!props.record?.id)
const formData = reactive({
unique_id: '',
const defaults = () => ({
service_identity: '',
server_identity: '',
name: '',
type: '',
manu: '',
model_num: '',
protocol: '',
ip: '',
tags: '',
category: 'router',
type: 'network',
host: '',
port: 161,
protocol: 'snmp',
community: 'public',
snmp_version: 'v2c',
vendor: '',
model: '',
location: '',
remark: '',
description: '',
enabled: true,
interval: 60,
tags: '',
snmpv3: {
security_level: 'noAuthNoPriv',
security_name: '',
auth_protocol: 'SHA',
auth_password: '',
priv_protocol: 'AES',
priv_password: '',
context_name: '',
},
ssh: {
port: 22,
username: '',
password: '',
private_key: '',
command_fib: '',
},
collect: { interface_enabled: true, arp_enabled: true, route_enabled: true, fib_enabled: true, timeout_sec: 5, retries: 1 },
})
const formData = reactive(defaults())
const rules = {
name: [{ required: true, message: '请输入设备名称' }],
type: [{ required: true, message: '请选择设备类型' }],
manu: [{ required: true, message: '请选择厂商' }],
category: [{ required: true, message: '请选择设备类型' }],
host: [{ required: true, message: '请输入设备地址' }],
community: [{ required: true, message: '请输入 SNMP community' }],
ssh_username: [{ required: true, message: '请输入 SSH 用户名' }],
}
watch(
() => props.visible,
(val) => {
if (val) {
if (isEdit.value && props.record) {
Object.assign(formData, props.record)
} else {
Object.assign(formData, {
unique_id: '',
name: '',
type: '',
manu: '',
model_num: '',
protocol: '',
ip: '',
tags: '',
location: '',
remark: '',
})
}
}
watch(() => props.visible, (val) => {
if (!val) return
if (isEdit.value && props.record) {
let parsedExtra: any = {}
try { parsedExtra = props.record.extra ? JSON.parse(props.record.extra) : {} } catch {}
const deviceCategory = (props.record.category && props.record.category !== 'network_device')
? props.record.category
: (props.record.type || 'other')
Object.assign(formData, defaults(), {
...props.record,
category: deviceCategory,
type: 'network',
collect: {
interface_enabled: parsedExtra.collect?.interface_enabled ?? true,
arp_enabled: parsedExtra.collect?.arp_enabled ?? true,
route_enabled: parsedExtra.collect?.route_enabled ?? true,
fib_enabled: parsedExtra.collect?.fib_enabled ?? true,
timeout_sec: parsedExtra.collect?.timeout_sec ?? 5,
retries: parsedExtra.collect?.retries ?? 1,
},
snmpv3: {
security_level: parsedExtra.snmp_v3?.security_level || 'noAuthNoPriv',
security_name: parsedExtra.snmp_v3?.security_name || '',
auth_protocol: parsedExtra.snmp_v3?.auth_protocol || 'SHA',
auth_password: '',
priv_protocol: parsedExtra.snmp_v3?.priv_protocol || 'AES',
priv_password: '',
context_name: parsedExtra.snmp_v3?.context_name || '',
},
ssh: {
port: parsedExtra.ssh?.port || 22,
username: parsedExtra.ssh?.username || props.record.username || '',
password: '',
private_key: parsedExtra.ssh?.private_key || '',
command_fib: parsedExtra.ssh?.command_fib || '',
},
})
tagList.value = (props.record.tags || '').split(',').map((i: string) => i.trim()).filter(Boolean)
} else {
Object.assign(formData, defaults())
tagList.value = []
}
)
})
watch(tagList, (tags) => { formData.tags = tags.join(',') })
const handleOk = async () => {
try {
await formRef.value?.validate()
if (formData.snmp_version !== 'v3' && !formData.community.trim()) {
Message.warning('请填写 SNMP community')
return
}
if (!formData.ssh.username.trim()) {
Message.warning('请填写 SSH 用户名')
return
}
if (!isEdit.value && !formData.ssh.password.trim()) {
Message.warning('请填写 SSH 密码')
return
}
confirmLoading.value = true
await new Promise((resolve) => setTimeout(resolve, 1000))
const payload: any = {
...formData,
type: 'network',
protocol: 'snmp',
username: formData.ssh.username,
extra: JSON.stringify({
schema_version: '1.0',
collect: formData.collect,
snmp_v2c: { port: formData.port, community: formData.community },
snmp_v3: {
port: formData.port,
security_level: formData.snmpv3.security_level,
security_name: formData.snmpv3.security_name,
auth_protocol: formData.snmpv3.auth_protocol,
auth_password: formData.snmpv3.auth_password,
priv_protocol: formData.snmpv3.priv_protocol,
priv_password: formData.snmpv3.priv_password,
context_name: formData.snmpv3.context_name,
},
ssh: formData.ssh,
}),
}
delete payload.snmpv3
delete payload.ssh
delete payload.collect
if (formData.ssh.password.trim()) {
payload.password = formData.ssh.password
} else {
delete payload.password
}
if (isEdit.value) await updateNetworkDevice(props.record.id, payload)
else await createNetworkDevice(payload)
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
handleCancel()
} catch (error) {
console.error('验证失败:', error)
} catch (error: any) {
if (error?.response?.data?.message) Message.error(error.response.data.message)
else Message.error('保存失败,请检查输入后重试')
} finally {
confirmLoading.value = false
}
}
const handleUpdateVisible = (value: boolean) => {
emit('update:visible', value)
}
const handleCancel = () => {
emit('update:visible', false)
formRef.value?.resetFields()
const handleUpdateVisible = (value: boolean) => emit('update:visible', value)
const handleCancel = () => { emit('update:visible', false); formRef.value?.resetFields() }
const loadServerOptions = async () => {
try {
const response: any = await fetchServerList({ page: 1, size: 1000 })
serverOptions.value = response?.details?.data || []
} catch {
serverOptions.value = []
}
}
onMounted(loadServerOptions)
</script>
<style scoped lang="less">
.mb-12 { margin-bottom: 12px; }
.mt-8 { margin-top: 8px; }
</style>

View File

@@ -1,60 +1,48 @@
export const columns = [
{
dataIndex: 'id',
title: 'ID',
width: 80,
slotName: 'id',
},
{
dataIndex: 'unique_id',
title: 'OID',
width: 150,
},
{
dataIndex: 'name',
title: '名称',
width: 150,
title: '设备名称',
width: 180,
},
{
dataIndex: 'type',
title: '类型',
dataIndex: 'category',
title: '设备类型',
width: 120,
slotName: 'category',
},
{
dataIndex: 'vendor',
title: '厂商',
width: 120,
},
{
dataIndex: 'manu',
title: '厂商',
width: 150,
},
{
dataIndex: 'model_num',
dataIndex: 'model',
title: '型号',
width: 150,
},
{
dataIndex: 'location',
title: '位置信息',
width: 150,
},
{
dataIndex: 'tags',
title: '标签',
width: 120,
},
{
dataIndex: 'ip',
title: 'IP地址',
width: 150,
dataIndex: 'host',
title: 'IP:端口',
width: 170,
slotName: 'host_port',
},
{
dataIndex: 'protocol',
title: '通讯协议',
width: 100,
title: '协议',
width: 130,
slotName: 'protocol',
},
{
dataIndex: 'indicator',
title: '网络设备指标',
width: 150,
slotName: 'indicator',
dataIndex: 'interval',
title: '采集间隔',
width: 110,
slotName: 'interval',
},
{
dataIndex: 'enabled',
title: '启用状态',
width: 100,
slotName: 'enabled',
},
{
dataIndex: 'status',
@@ -62,10 +50,28 @@ export const columns = [
width: 100,
slotName: 'status',
},
{
dataIndex: 'response_time',
title: '响应时间',
width: 110,
slotName: 'response_time',
},
{
dataIndex: 'last_check_time',
title: '最近检查',
width: 180,
slotName: 'last_check_time',
},
{
dataIndex: 'tags',
title: '标签',
width: 180,
slotName: 'tags',
},
{
dataIndex: 'actions',
title: '操作',
width: 180,
width: 200,
fixed: 'right' as const,
slotName: 'actions',
},

View File

@@ -5,35 +5,42 @@ export const searchFormConfig: FormItem[] = [
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入服务器名称、编码或IP',
placeholder: '请输入名称/IP/厂商/型号',
span: 6,
},
{
field: 'datacenter_id',
label: '数据中心',
field: 'enabled',
label: '启用状态',
type: 'select',
placeholder: '请选择数据中心',
options: [], // 需要动态加载
span: 6,
},
{
field: 'rack_id',
label: '机柜',
type: 'select',
placeholder: '请选择机柜',
options: [], // 需要动态加载
placeholder: '全部',
options: [
{ label: '已启用', value: true },
{ label: '已停用', value: false },
],
span: 6,
},
{
field: 'status',
label: '状态',
label: '设备状态',
type: 'select',
placeholder: '请选择状态',
placeholder: '全部',
options: [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '维护中', value: 'maintenance' },
{ label: '已退役', value: 'retired' },
{ label: '异常', value: 'error' },
{ label: '未知', value: 'unknown' },
],
span: 6,
},
{
field: 'protocol',
label: '采集协议',
type: 'select',
placeholder: '全部',
options: [
{ label: 'SNMP', value: 'snmp' },
{ label: 'SSH', value: 'ssh' },
{ label: 'Telnet', value: 'telnet' },
],
span: 6,
},

View File

@@ -7,7 +7,7 @@
:columns="columns"
:loading="loading"
:pagination="pagination"
title="网络设备管理"
title="网络设备数据采集管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@@ -16,298 +16,58 @@
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon>
<icon-plus />
</template>
新增网络设备
</a-button>
</template>
<!-- ID -->
<template #id="{ record }">
{{ record.id }}
</template>
<!-- 远程访问 -->
<template #remote_access="{ record }">
<a-tag :color="record.remote_access ? 'green' : 'gray'">
{{ record.remote_access ? '已开启' : '未开启' }}
</a-tag>
</template>
<!-- Agent配置 -->
<template #agent_config="{ record }">
<a-tag :color="record.agent_config ? 'green' : 'gray'">
{{ record.agent_config ? '已配置' : '未配置' }}
</a-tag>
</template>
<!-- CPU -->
<template #cpu="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-label">CPU</span>
<span class="resource-value">{{ record.cpu_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.cpu_info?.value || 0) / 100"
:color="getProgressColor(record.cpu_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 内存 -->
<template #memory="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-laebl">内存</span>
<span class="resource-value">{{ record.memory_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.memory_info?.value || 0) / 100"
:color="getProgressColor(record.memory_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 硬盘 -->
<template #disk="{ record }">
<div class="resource-display">
<div class="resource-info">
<span class="resource-label">硬盘</span>
<span class="resource-value">{{ record.disk_info?.value || 0 }}%</span>
</div>
<a-progress
:percent="(record.disk_info?.value || 0) / 100"
:color="getProgressColor(record.disk_info?.value || 0)"
size="small"
:show-text="false"
/>
</div>
</template>
<!-- 数据采集 -->
<template #data_collection="{ record }">
<a-tag :color="record.data_collection ? 'green' : 'gray'">
{{ record.data_collection ? '已启用' : '未启用' }}
</a-tag>
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 操作栏 - 下拉菜单 -->
<template #actions="{ record }">
<a-space>
<a-dropdown trigger="hover">
<a-button type="primary" size="small">
管理
<icon-down />
</a-button>
<template #content>
<a-doption @click="handleDetail(record)">
<template #icon>
<icon-eye />
</template>
详情
</a-doption>
<a-doption @click="handleEdit(record)">
<template #icon>
<icon-edit />
</template>
编辑
</a-doption>
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
<template #icon>
<icon-delete />
</template>
删除
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
<template #toolbar-left><a-space><a-button type="primary" @click="handleAdd"><template #icon><icon-plus /></template>新增设备</a-button><a-button type="outline" @click="handleRefresh"><template #icon><icon-refresh /></template>刷新状态</a-button></a-space></template>
<template #category="{ record }"><a-tag color="arcoblue">{{ formatDeviceCategory(record.category) }}</a-tag></template>
<template #host_port="{ record }">{{ record.host || '-' }}:{{ record.port || '-' }}</template>
<template #protocol="{ record }"><a-space><a-tag color="purple">{{ String(record.protocol || '-').toUpperCase() }}</a-tag><a-tag v-if="record.protocol === 'snmp'" color="orangered">{{ String(record.snmp_version || '-').toUpperCase() }}</a-tag></a-space></template>
<template #interval="{ record }">{{ record.interval || 0 }} </template>
<template #enabled="{ record }"><a-tag :color="record.enabled ? 'green' : 'gray'">{{ record.enabled ? '已启用' : '已停用' }}</a-tag></template>
<template #status="{ record }"><a-tooltip :content="statusHint(record.status)"><a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag></a-tooltip></template>
<template #response_time="{ record }"><span :style="{ color: getResponseTimeColor(record.response_time) }">{{ formatResponseTime(record.response_time) }}</span></template>
<template #last_check_time="{ record }">{{ formatTime(record.last_check_time) }}</template>
<template #tags="{ record }"><a-tag v-for="(tag, idx) in parseTags(record.tags)" :key="idx" style="margin-right: 4px">{{ tag }}</a-tag><span v-if="!parseTags(record.tags).length">-</span></template>
<template #actions="{ record }"><a-space><a-button type="text" size="small" @click="handleDetail(record)">详情</a-button><a-button type="text" size="small" @click="handleEdit(record)">编辑</a-button><a-button type="text" size="small" status="danger" @click="handleDelete(record)">删除</a-button></a-space></template>
</search-table>
<!-- 新增/编辑对话框 -->
<FormDialog
v-model:visible="formDialogVisible"
:record="currentRecord"
@success="handleFormSuccess"
/>
<!-- 快捷配置对话框 -->
<QuickConfigDialog
v-model:visible="quickConfigVisible"
:record="currentRecord"
@success="handleFormSuccess"
/>
<FormDialog v-model:visible="formDialogVisible" :record="currentRecord" @success="handleFormSuccess" />
<a-drawer v-model:visible="detailVisible" :width="860" title="网络设备采集详情" :footer="false" unmount-on-close>
<Detail v-if="currentRecord" :record="currentRecord" @edit="handleDetailEdit" @delete="handleDetailDelete" />
</a-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, computed, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import {
IconPlus,
IconDown,
IconEdit,
IconDesktop,
IconDelete,
IconRefresh,
IconEye,
IconSettings
} from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import FormDialog from './components/FormDialog.vue'
import Detail from './components/Detail.vue'
import { columns as columnsConfig } from './config/columns'
import {
fetchServerList,
deleteServer,
} from '@/api/ops/server'
fetchNetworkDeviceList,
fetchNetworkDeviceDetail,
deleteNetworkDevice,
type NetworkDeviceService,
type NetworkDeviceQueryParams,
} from '@/api/ops/network-device'
const router = useRouter()
// Mock 假数据
const mockServerData = [
{
id: 1,
unique_id: 'NET-2024-0001',
name: '核心交换机 -01',
type: '交换机',
manu: '华为',
model_num: 'CE6857-48S6CQ',
location: '数据中心 A-3 层 -24 机柜 -5U 位',
tags: '核心,生产',
ip: '192.168.1.1',
protocol: 'SNMP',
indicator: 'CPU:45%,内存:62%',
status: 'online',
},
{
id: 2,
unique_id: 'NET-2024-0002',
name: '汇聚路由器 -01',
type: '路由器',
manu: '思科',
model_num: 'ISR4451-X/K9',
location: '数据中心 A-3 层 -24 机柜 -6U 位',
tags: '汇聚,生产',
ip: '192.168.1.2',
protocol: 'SNMP',
indicator: 'CPU:78%,内存:85%',
status: 'online',
},
{
id: 3,
unique_id: 'NET-2024-0003',
name: '接入交换机 -01',
type: '交换机',
manu: '华三',
model_num: 'S5560X-54C-EI',
location: '数据中心 B-1 层 -12 机柜 -1U 位',
tags: '接入,办公',
ip: '192.168.2.1',
protocol: 'SNMP',
indicator: '-',
status: 'offline',
},
{
id: 4,
unique_id: 'NET-2024-0004',
name: '防火墙 -01',
type: '防火墙',
manu: '深信服',
model_num: 'AF-2000',
location: '数据中心 B-1 层 -12 机柜 -2U 位',
tags: '安全,边界',
ip: '192.168.1.254',
protocol: 'SNMP',
indicator: 'CPU:35%,内存:68%',
status: 'online',
},
{
id: 5,
unique_id: 'NET-2024-0005',
name: '负载均衡器 -01',
type: '负载均衡',
manu: 'F5',
model_num: 'BIG-IP i2800',
location: '数据中心 C-2 层 -8 机柜 -3U 位',
tags: '负载,应用',
ip: '192.168.3.1',
protocol: 'SNMP',
indicator: 'CPU:28%,内存:45%',
status: 'maintenance',
},
{
id: 6,
unique_id: 'NET-2024-0006',
name: '无线控制器 -01',
type: '无线控制器',
manu: '锐捷',
model_num: 'WS6008',
location: '数据中心 A-1 层 -05 机柜 -8U 位',
tags: '无线,办公',
ip: '192.168.4.1',
protocol: 'SNMP',
indicator: '-',
status: 'retired',
},
{
id: 7,
unique_id: 'NET-2024-0007',
name: '光模块交换机 -01',
type: '交换机',
manu: '华为',
model_num: 'CE8850-64CQ-EI',
location: '数据中心 A-2 层 -15 机柜 -10U 位',
tags: '高速,存储网络',
ip: '192.168.5.1',
protocol: 'SNMP',
indicator: 'CPU:55%,内存:72%',
status: 'online',
},
{
id: 8,
unique_id: 'NET-2024-0008',
name: '管理交换机 -01',
type: '交换机',
manu: 'TP-LINK',
model_num: 'TL-SG3428',
location: '数据中心 B-2 层 -18 机柜 -12U 位',
tags: '管理,带外',
ip: '192.168.100.1',
protocol: 'SNMP',
indicator: 'CPU:42%,内存:38%',
status: 'online',
},
]
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const tableData = ref<NetworkDeviceService[]>([])
const formDialogVisible = ref(false)
const quickConfigVisible = ref(false)
const currentRecord = ref<any>(null)
const detailVisible = ref(false)
const currentRecord = ref<NetworkDeviceService | null>(null)
const formModel = ref({
keyword: '',
datacenter_id: undefined,
rack_id: undefined,
status: undefined,
enabled: undefined as boolean | undefined,
status: undefined as string | undefined,
protocol: undefined as string | undefined,
})
const pagination = reactive({
@@ -322,70 +82,90 @@ const formItems = computed<FormItem[]>(() => searchFormConfig)
// 表格列配置
const columns = computed(() => columnsConfig)
// 获取状态颜色
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
maintenance: 'orange',
retired: 'gray',
error: 'orange',
unknown: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取状态文本
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
maintenance: '维护中',
retired: '已退役',
error: '异常',
unknown: '未知',
}
return textMap[status || ''] || '-'
}
// 获取进度条颜色
const getProgressColor = (value: number) => {
if (value >= 90) return '#F53F3F' // 红色
if (value >= 70) return '#FF7D00' // 橙色
if (value >= 50) return '#FFD00B' // 黄色
return '#00B42A' // 绿色
const statusHint = (status?: string) => {
const hintMap: Record<string, string> = {
online: '在线:设备采集正常',
offline: '离线:采集任务未拿到设备响应',
error: '异常:采集发生错误,请检查配置',
unknown: '未知:等待首次采集',
}
return hintMap[status || 'unknown']
}
// 获取网络设备列表(使用 Mock 数据)
const fetchServers = async () => {
const formatResponseTime = (value?: number) => {
if (typeof value !== 'number' || value <= 0) return '-'
return `${value.toFixed(2)} ms`
}
const getResponseTimeColor = (value?: number) => {
if (!value) return ''
if (value >= 1000) return 'rgb(var(--danger-6))'
if (value >= 500) return 'rgb(var(--warning-6))'
return 'rgb(var(--success-6))'
}
const formatTime = (time?: string) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(
date.getHours()
).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
const parseTags = (tags?: string) => {
if (!tags) return []
return tags.split(',').map((i) => i.trim()).filter(Boolean)
}
const formatDeviceCategory = (category?: string) => {
const map: Record<string, string> = {
router: '路由器',
switch: '交换机',
firewall: '防火墙',
access_point: '接入点',
load_balancer: '负载均衡',
other: '其他',
}
if (!category) return '-'
return map[category] || category
}
const fetchList = async () => {
loading.value = true
try {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
// 使用 Mock 数据
tableData.value = mockServerData
pagination.total = mockServerData.length
// 如果有搜索条件,进行过滤
if (formModel.value.keyword || formModel.value.status) {
let filteredData = [...mockServerData]
if (formModel.value.keyword) {
const keyword = formModel.value.keyword.toLowerCase()
filteredData = filteredData.filter(item =>
item.name.toLowerCase().includes(keyword) ||
item.unique_id.toLowerCase().includes(keyword) ||
item.ip.toLowerCase().includes(keyword)
)
}
if (formModel.value.status) {
filteredData = filteredData.filter(item => item.status === formModel.value.status)
}
tableData.value = filteredData
pagination.total = filteredData.length
}
const params: NetworkDeviceQueryParams = { page: pagination.current, size: pagination.pageSize }
if (formModel.value.keyword) params.keyword = formModel.value.keyword
if (formModel.value.enabled !== undefined) params.enabled = formModel.value.enabled
const res: any = await fetchNetworkDeviceList(params)
let data: NetworkDeviceService[] = res?.details?.data || []
if (formModel.value.status) data = data.filter((item) => item.status === formModel.value.status)
if (formModel.value.protocol) data = data.filter((item) => item.protocol === formModel.value.protocol)
tableData.value = data
pagination.total = formModel.value.status || formModel.value.protocol ? data.length : (res?.details?.total || 0)
} catch (error) {
console.error('获取网络设备列表失败:', error)
Message.error('获取网络设备列表失败')
tableData.value = []
pagination.total = 0
@@ -394,132 +174,94 @@ const fetchServers = async () => {
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchServers()
fetchList()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
datacenter_id: undefined,
rack_id: undefined,
status: undefined,
enabled: undefined,
status: undefined as string | undefined,
protocol: undefined as string | undefined,
}
pagination.current = 1
fetchServers()
fetchList()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchServers()
fetchList()
}
// 刷新
const handleRefresh = () => {
fetchServers()
fetchList()
Message.success('数据已刷新')
}
// 新增网络设备
const handleAdd = () => {
currentRecord.value = null
formDialogVisible.value = true
}
// 快捷配置
const handleQuickConfig = (record: any) => {
currentRecord.value = record
quickConfigVisible.value = true
}
// 编辑网络设备
const handleEdit = (record: any) => {
const handleEdit = (record: NetworkDeviceService) => {
currentRecord.value = record
formDialogVisible.value = true
}
// 表单提交成功
const handleFormSuccess = () => {
fetchServers()
fetchList()
}
// 重启网络设备
const handleRestart = (record: any) => {
Modal.confirm({
title: '确认重启',
content: `确认重启网络设备 ${record.name} 吗?`,
onOk: () => {
Message.info('正在发送重启指令...')
},
})
}
// 查看详情 - 在当前窗口打开
const handleDetail = (record: any) => {
router.push({
path: '/dc/detail',
query: {
id: record.id,
name: record.name,
ip: record.ip,
status: record.status,
},
})
}
// 远程控制 - 在新窗口打开
const handleRemoteControl = (record: any) => {
const url = router.resolve({
path: '/dc/remote',
query: {
id: record.id,
name: record.name,
ip: record.ip,
status: record.status,
},
}).href
window.open(url, '_blank')
}
// 删除网络设备
const handleDelete = async (record: any) => {
const handleDetail = async (record: NetworkDeviceService) => {
try {
Modal.confirm({
title: '确认删除',
content: `确认删除网络设备 ${record.name} 吗?`,
onOk: async () => {
// Mock 删除操作
const index = mockServerData.findIndex(item => item.id === record.id)
if (index > -1) {
mockServerData.splice(index, 1)
Message.success('删除成功')
fetchServers()
} else {
Message.error('删除失败')
}
},
})
} catch (error) {
console.error('删除网络设备失败:', error)
const res: any = await fetchNetworkDeviceDetail(record.id)
currentRecord.value = (res?.details || record) as NetworkDeviceService
detailVisible.value = true
} catch {
currentRecord.value = record
detailVisible.value = true
}
}
// 初始化加载数据
fetchServers()
const handleDetailEdit = () => {
detailVisible.value = false
formDialogVisible.value = true
}
const handleDetailDelete = () => {
detailVisible.value = false
if (currentRecord.value) handleDelete(currentRecord.value)
}
const handleDelete = (record: NetworkDeviceService) => {
Modal.confirm({
title: '确认删除',
content: `确认删除设备「${record.name}」吗?删除为软删除,建议先停用后再执行。`,
okButtonProps: { status: 'danger' },
onOk: async () => {
try {
await deleteNetworkDevice(record.id)
Message.success('删除成功')
fetchList()
} catch (error: any) {
Message.error(error?.response?.data?.message || '删除失败')
}
},
})
}
onMounted(fetchList)
</script>
<script lang="ts">
export default {
name: 'DataCenterServer',
name: 'NetworkDeviceCollectionManagement',
}
</script>
@@ -527,38 +269,4 @@ export default {
.container {
margin-top: 20px;
}
.resource-display {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
.resource-info {
display: flex;
align-items: center;
justify-content: space-between;
> div {
display: inline-block;
}
.resource-value {
font-size: 12px;
font-weight: 500;
color: rgb(var(--text-1));
}
}
:deep(.arco-progress) {
margin: 0;
.arco-progress-bar-bg {
border-radius: 2px;
}
.arco-progress-bar {
border-radius: 2px;
transition: all 0.3s ease;
}
}
}
</style>

View File

@@ -160,7 +160,10 @@ const handleViewMetrics = async () => {
const formatTime = (time?: string) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -310,7 +310,10 @@ const loadServerOptions = async () => {
const formatTime = (time?: string) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -578,6 +578,8 @@ const formatTime = (time?: string | Date) => {
if (!time) return '-'
try {
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',

View File

@@ -100,7 +100,7 @@ const getStatusText = (status?: string) => {
const formatTime = (time?: string | null) => {
if (!time || time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1) return '-'
if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -159,7 +159,7 @@ const getStatusText = (status?: string) => {
const formatTime = (time?: string | null) => {
if (!time || time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1) return '-'
if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1970) return '-'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')

View File

@@ -1,218 +1,108 @@
<template>
<div class="container">
<!-- 统计卡片 -->
<a-row :gutter="16" class="stats-row">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-primary">
<icon-storage />
</div>
<div class="stats-info">
<div class="stats-title">网络设备</div>
<div class="stats-value">32</div>
<div class="stats-desc">在线 31 / 离线 1</div>
</div>
</div>
<div class="stats-title">受管设备</div>
<div class="stats-value">{{ overview.total_devices ?? 0 }}</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-cyan">
<icon-drive-file />
</div>
<div class="stats-info">
<div class="stats-title">端口总数</div>
<div class="stats-value">1,024</div>
<div class="stats-desc">使用率 78%</div>
</div>
</div>
<div class="stats-title">在线设备</div>
<div class="stats-value text-success">{{ overview.online_devices ?? 0 }}</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-green">
<icon-link />
</div>
<div class="stats-info">
<div class="stats-title">总带宽</div>
<div class="stats-value">45.6 Gbps</div>
<div class="stats-desc">峰值流量</div>
</div>
</div>
<div class="stats-title">离线/异常</div>
<div class="stats-value text-danger">{{ overview.abnormal_devices ?? 0 }}</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-warning">
<icon-exclamation-circle />
<div class="stats-title">平均响应时延</div>
<div class="stats-value">{{ Number(overview.avg_response_time_ms || 0).toFixed(1) }} ms</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" class="section-row">
<a-col :xs="24" :lg="8">
<a-card title="设备健康矩阵" :bordered="false">
<div class="matrix-list">
<div v-for="item in healthMatrix" :key="`${item.error_bucket}-${item.latency_bucket}`" class="matrix-item">
<span>{{ item.error_bucket }} / {{ item.latency_bucket }}</span>
<a-tag>{{ item.count }}</a-tag>
</div>
<div class="stats-info">
<div class="stats-title">告警设备</div>
<div class="stats-value">2</div>
<div class="stats-desc text-danger">需要关注</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="8">
<a-card title="采集新鲜度分布" :bordered="false">
<div class="simple-list">
<div v-for="item in freshness" :key="item.bucket_label" class="simple-item">
<span>{{ item.bucket_label }}</span>
<b>{{ item.count }}</b>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="8">
<a-card title="协议/版本分布" :bordered="false">
<div class="simple-list">
<div v-for="item in protocols" :key="`${item.protocol}-${item.snmp_version}`" class="simple-item">
<span>{{ item.protocol || '-' }} / {{ item.snmp_version || '-' }}</span>
<b>{{ item.count }}</b>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 图表区域 -->
<a-row :gutter="16" class="chart-row">
<a-row :gutter="16" class="section-row">
<a-col :xs="24" :lg="12">
<a-card title="网络流量趋势" :bordered="false">
<template #extra>
<a-space>
<span class="legend-item">
<span class="legend-dot legend-dot-1"></span>
<span>入站</span>
</span>
<span class="legend-item">
<span class="legend-dot legend-dot-2"></span>
<span>出站</span>
</span>
</a-space>
</template>
<div class="chart-container">
<Chart :options="trafficChartOptions" height="280px" />
</div>
<a-card title="资源概览" :bordered="false">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="接口总量">{{ resource.interface_summary?.total || 0 }}</a-descriptions-item>
<a-descriptions-item label="接口Up/Down">
{{ resource.interface_summary?.up || 0 }} / {{ resource.interface_summary?.down || 0 }}
</a-descriptions-item>
<a-descriptions-item label="ARP总量">{{ resource.arp_summary?.total || 0 }}</a-descriptions-item>
<a-descriptions-item label="ARP去重IP">{{ resource.arp_summary?.unique_ip_count || 0 }}</a-descriptions-item>
<a-descriptions-item label="路由总量">{{ resource.route_summary?.total || 0 }}</a-descriptions-item>
<a-descriptions-item label="FIB总量">{{ resource.fib_summary?.total || 0 }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="Top 5 端口流量" :bordered="false">
<template #extra>
<span class="text-muted">当前流量最大的端口</span>
</template>
<div class="port-list">
<div v-for="item in topPorts" :key="item.name" class="port-item">
<div class="port-header">
<span class="port-name">{{ item.name }}</span>
<span class="port-value">{{ item.value }}</span>
</div>
<a-progress
:percent="item.percent"
:stroke-width="8"
:show-text="false"
:color="getPortColor(item.percent)"
/>
<a-card title="异常事件带" :bordered="false">
<div class="simple-list">
<div v-for="item in alerts.top_continuous_errors || []" :key="item.id" class="simple-item">
<span>{{ item.name }} ({{ item.host }})</span>
<a-tag color="red">连续错误 {{ item.continuous_errors }}</a-tag>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 快速统计 -->
<a-row :gutter="16" class="quick-stats-row">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="quick-stats-card" :bordered="false">
<div class="quick-stats-header">
<div class="quick-stats-icon quick-stats-icon-primary">
<icon-storage />
</div>
<div class="quick-stats-info">
<div class="quick-stats-title">核心层</div>
<div class="quick-stats-desc">2 设备在线</div>
</div>
</div>
<div class="quick-stats-footer">
<span class="quick-stats-value">99.9%</span>
<a-tag color="green">正常</a-tag>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="quick-stats-card" :bordered="false">
<div class="quick-stats-header">
<div class="quick-stats-icon quick-stats-icon-cyan">
<icon-apps />
</div>
<div class="quick-stats-info">
<div class="quick-stats-title">汇聚层</div>
<div class="quick-stats-desc">4 设备在线</div>
</div>
</div>
<div class="quick-stats-footer">
<span class="quick-stats-value">100%</span>
<a-tag color="green">正常</a-tag>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="quick-stats-card" :bordered="false">
<div class="quick-stats-header">
<div class="quick-stats-icon quick-stats-icon-purple">
<icon-drive-file />
</div>
<div class="quick-stats-info">
<div class="quick-stats-title">接入层</div>
<div class="quick-stats-desc">24 设备在线</div>
</div>
</div>
<div class="quick-stats-footer">
<span class="quick-stats-value">95.8%</span>
<a-tag color="orange">告警</a-tag>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="quick-stats-card" :bordered="false">
<div class="quick-stats-header">
<div class="quick-stats-icon quick-stats-icon-green">
<icon-wifi />
</div>
<div class="quick-stats-info">
<div class="quick-stats-title">无线网络</div>
<div class="quick-stats-desc">1 控制器离线</div>
</div>
</div>
<div class="quick-stats-footer">
<span class="quick-stats-value">0%</span>
<a-tag color="red">异常</a-tag>
</div>
</a-card>
</a-col>
</a-row>
<!-- 设备列表 -->
<a-card title="设备列表" :bordered="false">
<a-card title="设备明细" :bordered="false">
<template #extra>
<a-select v-model="selectedType" placeholder="全部类型" style="width: 150px">
<a-option value="">全部类型</a-option>
<a-option value="核心交换机">核心交换机</a-option>
<a-option value="汇聚交换机">汇聚交换机</a-option>
<a-option value="接入交换机">接入交换机</a-option>
<a-option value="边界路由">路由器</a-option>
<a-option value="无线控制器">无线设备</a-option>
</a-select>
<a-space>
<a-input-search v-model="filters.keyword" placeholder="设备名/IP/型号" style="width: 220px" @search="fetchDevices" />
<a-select v-model="filters.status" placeholder="状态" allow-clear style="width: 120px" @change="fetchDevices">
<a-option value="online">online</a-option>
<a-option value="offline">offline</a-option>
<a-option value="error">error</a-option>
<a-option value="unknown">unknown</a-option>
</a-select>
<a-button type="primary" @click="fetchAll">刷新</a-button>
</a-space>
</template>
<a-table
:data="filteredDevices"
:columns="columns"
:loading="loading"
:pagination="false"
row-key="name"
>
<!-- 状态列 -->
<a-table :data="devices" :columns="columns" :loading="loading" row-key="id" :pagination="pagination">
<template #status="{ record }">
<a-tag :color="getStatusColor(record.statusValue)" bordered>
{{ record.statusText }}
</a-tag>
</template>
<!-- 带宽使用列 -->
<template #traffic="{ record }">
<div class="traffic-cell">
<a-progress
:percent="record.trafficPercent"
:stroke-width="8"
:show-text="false"
:color="getTrafficColor(record.trafficPercent)"
/>
<span class="traffic-text">{{ record.trafficPercent }}%</span>
</div>
<a-tag :color="statusColor(record.status)">{{ record.status }}</a-tag>
</template>
</a-table>
</a-card>
@@ -220,259 +110,122 @@
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import {
IconStorage,
IconDriveFile,
IconLink,
IconExclamationCircle,
IconApps,
IconWifi,
} from '@arco-design/web-vue/es/icon'
import Breadcrumb from '@/components/breadcrumb/index.vue'
import Chart from '@/components/chart/index.vue'
import { onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import { Message } from '@arco-design/web-vue'
import {
fetchNetworkScreenAlertsStream,
fetchNetworkScreenDevices,
fetchNetworkScreenFreshnessDistribution,
fetchNetworkScreenHealthMatrix,
fetchNetworkScreenOverview,
fetchNetworkScreenProtocolDistribution,
fetchNetworkScreenResourceOverview,
} from '@/api/ops/network-screen'
// 加载状态
const loading = ref(false)
const selectedType = ref('')
const overview = reactive<Record<string, any>>({})
const healthMatrix = ref<any[]>([])
const freshness = ref<any[]>([])
const protocols = ref<any[]>([])
const resource = reactive<Record<string, any>>({})
const alerts = reactive<Record<string, any>>({})
const devices = ref<any[]>([])
const pollTimer = ref<number | null>(null)
// 统计数据
const topPorts = ref([
{ name: 'Core-SW-01 Port 1', value: '8.5 Gbps', percent: 85 },
{ name: 'Core-SW-02 Port 1', value: '7.2 Gbps', percent: 72 },
{ name: 'Router-01 Port 1', value: '6.8 Gbps', percent: 68 },
{ name: 'Dist-SW-01 Port 24', value: '4.5 Gbps', percent: 45 },
{ name: 'Access-SW-01 Port 48', value: '3.2 Gbps', percent: 32 },
])
const filters = reactive({
keyword: '',
status: '',
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
onChange: (page: number) => {
pagination.current = page
fetchDevices()
},
})
// 表格列配置
const columns: TableColumnData[] = [
{
title: '设备名称',
dataIndex: 'name',
width: 150,
},
{
title: '类型',
dataIndex: 'type',
width: 120,
},
{
title: '型号',
dataIndex: 'model',
width: 180,
},
{
title: '管理IP',
dataIndex: 'ip',
width: 130,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
align: 'center',
},
{
title: '运行时间',
dataIndex: 'uptime',
width: 140,
},
{
title: '端口使用',
dataIndex: 'ports',
width: 100,
align: 'center',
},
{
title: '带宽使用',
dataIndex: 'traffic',
slotName: 'traffic',
width: 200,
},
{ title: '设备', dataIndex: 'name' },
{ title: '管理IP', dataIndex: 'host' },
{ title: '协议', dataIndex: 'protocol' },
{ title: '厂商', dataIndex: 'vendor' },
{ title: '状态', dataIndex: 'status', slotName: 'status' },
{ title: '响应时延(ms)', dataIndex: 'response_time' },
{ title: '最后检查时间', dataIndex: 'last_check_time' },
{ title: '连续错误', dataIndex: 'continuous_errors' },
]
// 设备数据
const networkDevices = ref([
{
name: 'Core-SW-01',
type: '核心交换机',
model: 'Cisco Nexus 9336C',
ip: '10.0.0.1',
statusValue: 'online',
statusText: '在线',
uptime: '365天 12小时',
ports: '36/36',
trafficPercent: 45,
},
{
name: 'Core-SW-02',
type: '核心交换机',
model: 'Cisco Nexus 9336C',
ip: '10.0.0.2',
statusValue: 'online',
statusText: '在线',
uptime: '365天 12小时',
ports: '34/36',
trafficPercent: 52,
},
{
name: 'Dist-SW-01',
type: '汇聚交换机',
model: 'Cisco Catalyst 9500',
ip: '10.0.1.1',
statusValue: 'online',
statusText: '在线',
uptime: '180天 8小时',
ports: '24/48',
trafficPercent: 38,
},
{
name: 'Access-SW-01',
type: '接入交换机',
model: 'Cisco Catalyst 9300',
ip: '10.0.2.1',
statusValue: 'warning',
statusText: '高负载',
uptime: '90天 4小时',
ports: '45/48',
trafficPercent: 88,
},
{
name: 'Router-01',
type: '边界路由',
model: 'Cisco ASR 1002-HX',
ip: '10.0.0.254',
statusValue: 'online',
statusText: '在线',
uptime: '425天 6小时',
ports: '4/4',
trafficPercent: 62,
},
{
name: 'AP-Controller',
type: '无线控制器',
model: 'Cisco 9800-40',
ip: '10.0.3.1',
statusValue: 'offline',
statusText: '离线',
uptime: '-',
ports: '-',
trafficPercent: 0,
},
])
const statusColor = (status: string) => {
if (status === 'online') return 'green'
if (status === 'offline') return 'red'
if (status === 'error') return 'orangered'
return 'orange'
}
// 过滤后的设备列表
const filteredDevices = computed(() => {
if (!selectedType.value) {
return networkDevices.value
const fetchDevices = async () => {
loading.value = true
try {
const res: any = await fetchNetworkScreenDevices({
page: pagination.current,
size: pagination.pageSize,
keyword: filters.keyword || undefined,
status: filters.status || undefined,
})
devices.value = res?.details?.data || []
pagination.total = res?.details?.total || 0
} finally {
loading.value = false
}
return networkDevices.value.filter((device) => device.type === selectedType.value)
}
const fetchAll = async () => {
try {
const [a, b, c, d, e, f] = await Promise.all([
fetchNetworkScreenOverview(),
fetchNetworkScreenHealthMatrix({ window: '15m' }),
fetchNetworkScreenFreshnessDistribution(),
fetchNetworkScreenProtocolDistribution(),
fetchNetworkScreenResourceOverview({ window: '1h' }),
fetchNetworkScreenAlertsStream({ limit: 10 }),
])
Object.assign(overview, a?.details || {})
healthMatrix.value = b?.details?.data || []
freshness.value = c?.details?.data || []
protocols.value = d?.details?.data || []
Object.assign(resource, e?.details || {})
Object.assign(alerts, f?.details || {})
await fetchDevices()
} catch (e) {
Message.error('网络设备大屏数据加载失败')
}
}
onMounted(async () => {
await fetchAll()
pollTimer.value = window.setInterval(fetchAll, 30000)
})
// 流量趋势图表配置
const trafficChartOptions = ref({
tooltip: {
trigger: 'axis',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
},
yAxis: {
type: 'value',
name: 'Gbps',
},
series: [
{
name: '入站',
type: 'line',
smooth: true,
data: [2.5, 1.2, 8.5, 12.4, 10.8, 5.6, 3.2],
areaStyle: {
opacity: 0.1,
},
lineStyle: {
width: 2,
color: '#165DFF',
},
itemStyle: {
color: '#165DFF',
},
},
{
name: '出站',
type: 'line',
smooth: true,
data: [1.8, 0.8, 6.2, 9.8, 8.2, 4.2, 2.4],
areaStyle: {
opacity: 0.1,
},
lineStyle: {
width: 2,
color: '#14C9C9',
},
itemStyle: {
color: '#14C9C9',
},
},
],
})
// 获取状态颜色
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
warning: 'orange',
}
return colorMap[status] || 'gray'
}
// 获取端口流量颜色
const getPortColor = (percent: number) => {
if (percent >= 80) return '#F53F3F'
if (percent >= 60) return '#FF7D00'
return '#165DFF'
}
// 获取带宽使用颜色
const getTrafficColor = (percent: number) => {
if (percent >= 80) return '#F53F3F'
if (percent >= 60) return '#FF7D00'
return '#165DFF'
}
// 获取数据
const fetchData = async () => {
// TODO: 从API获取数据
loading.value = false
}
// 初始化
onMounted(() => {
fetchData()
onBeforeUnmount(() => {
if (pollTimer.value) window.clearInterval(pollTimer.value)
})
</script>
<script lang="ts">
export default {
name: 'NetworkMonitor',
name: 'NetworkMonitorScreen',
}
</script>
<style scoped lang="less">
.container {
padding: 16px;
min-height: calc(100vh - 120px);
box-sizing: border-box;
overflow: auto;
}
.stats-row {
@@ -480,230 +233,30 @@ export default {
}
.stats-card {
height: 100%;
:deep(.arco-card-body) {
padding: 16px;
}
.stats-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
&-primary {
background-color: rgba(22, 93, 255, 0.1);
color: rgb(var(--primary-6));
}
&-cyan {
background-color: rgba(20, 201, 201, 0.1);
color: #14c9c9;
}
&-green {
background-color: rgba(0, 180, 42, 0.1);
color: rgb(var(--success-6));
}
&-warning {
background-color: rgba(255, 125, 0, 0.1);
color: rgb(var(--warning-6));
}
}
.stats-info {
flex: 1;
}
.stats-title {
font-size: 14px;
color: var(--color-text-3);
margin-bottom: 4px;
}
.stats-value {
font-size: 24px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.2;
margin-bottom: 4px;
}
.stats-desc {
font-size: 12px;
color: var(--color-text-3);
}
.stats-title { color: var(--color-text-2); margin-bottom: 8px; }
.stats-value { font-size: 28px; font-weight: 700; }
}
.chart-row {
.section-row {
margin-bottom: 16px;
}
.chart-container {
height: 280px;
.matrix-list,
.simple-list {
max-height: 260px;
overflow: auto;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--color-text-3);
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&-1 {
background-color: #165dff;
}
&-2 {
background-color: #14c9c9;
}
}
.port-list {
padding: 8px 0;
}
.port-item {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.port-header {
.matrix-item,
.simple-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.port-name {
font-size: 14px;
color: var(--color-text-2);
}
.port-value {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
.quick-stats-row {
margin-bottom: 16px;
}
.quick-stats-card {
height: 100%;
:deep(.arco-card-body) {
padding: 16px;
}
.quick-stats-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.quick-stats-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
&-primary {
background-color: rgba(22, 93, 255, 0.1);
color: rgb(var(--primary-6));
}
&-cyan {
background-color: rgba(20, 201, 201, 0.1);
color: #14c9c9;
}
&-purple {
background-color: rgba(114, 46, 209, 0.1);
color: #722ed1;
}
&-green {
background-color: rgba(0, 180, 42, 0.1);
color: rgb(var(--success-6));
}
}
.quick-stats-info {
flex: 1;
}
.quick-stats-title {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
.quick-stats-desc {
font-size: 12px;
color: var(--color-text-3);
}
.quick-stats-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.quick-stats-value {
font-size: 20px;
font-weight: 600;
color: var(--color-text-1);
}
}
.traffic-cell {
display: flex;
align-items: center;
gap: 8px;
}
.traffic-text {
font-size: 12px;
color: var(--color-text-3);
min-width: 40px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border-2);
}
.text-danger {
color: rgb(var(--danger-6));
}
.text-success {
color: rgb(var(--success-6));
}
.text-muted {
font-size: 12px;
color: var(--color-text-3);
}
.text-success { color: rgb(var(--success-6)); }
</style>

View File

@@ -353,7 +353,11 @@ const formatThroughput = (val: number | null) => {
const formatTime = (time: string) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
if (time.startsWith('0001-01-01')) return '-'
const date = new Date(time)
if (Number.isNaN(date.getTime())) return '-'
if (date.getFullYear() <= 1970) return '-'
return date.toLocaleString('zh-CN')
}
const getStatusColor = (status: string) => {

View File

@@ -327,7 +327,11 @@ const getTypeColor = (type?: string) => {
// 格式化时间
const formatTime = (time?: string) => {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'
const d = dayjs(time)
if (!d.isValid() || d.year() <= 1970) return '-'
return d.format('YYYY-MM-DD HH:mm')
}
</script>