Files
NetMesh/components/monitoring/MonitoringTopologyView.tsx
zhaolei c64e00d835 feat: 新增设备巡检模块实现完整的巡检告警功能
新增设备巡检模块核心功能,包括:
- 巡检数据存储与调度系统
- 华为/H3C交换机及Linux服务器采集器
- 设备扩展配置与告警规则管理
- 主机详情页巡检配置入口
- 产品名称自定义设置功能
- 网络设备SSH exec通道兼容处理
2026-10-09 10:37:48 +08:00

2410 lines
90 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 运行监控 - 3D 拓扑大屏
*
* 移植自 ITYUNWEI TopologyView.vue
* Three.js 场景 + ECharts 图表 + NetMesh 真实数据
*/
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
import * as echarts from 'echarts';
import {
Network,
Clock,
Maximize2,
Minimize2,
RefreshCw,
Server,
Activity,
TrendingUp,
AlertTriangle,
CheckCircle2,
ListTodo,
XCircle,
Loader2,
Bell,
Expand,
} from 'lucide-react';
import { useMonitoringData } from './useMonitoringData';
import MonitoringDetailPanel, { type PanelType } from './MonitoringDetailPanel';
import { createDeviceModel, type DeviceType, type DeviceStatus } from './deviceModels';
import type { Host } from '../../domain/models';
interface Props {
hosts: Host[];
keys?: any[];
}
// 设备类型映射
const typeLabel: Record<string, string> = {
server: '服务器',
switch: '交换机',
router: '路由器',
firewall: '防火墙',
storage: '存储',
workstation: '工作站',
h3c: '交换机',
huawei: '交换机',
};
// 状态颜色
const statusColors: Record<string, number> = {
normal: 0x10b981,
warning: 0xf59e0b,
critical: 0xef4444,
offline: 0x6b7280,
};
const statusHex: Record<string, string> = {
normal: '#10b981',
warning: '#f59e0b',
critical: '#ef4444',
offline: '#6b7280',
};
/** 标签样式唯一入口:根据设备状态返回渐变背景 + 边框色,兜底灰色 */
function getLabelStyle(status: string): { background: string; border: string; glow: string } {
const hex = statusHex[status] ?? statusHex.offline;
return {
background: `linear-gradient(135deg, ${hex}ee, ${hex}88)`,
border: `1px solid ${hex}cc`,
glow: `${hex}55`,
};
}
// 链路颜色
const edgeColorMap: Record<string, number> = {
wan: 0x22d3ee,
uplink: 0x38bdf8,
l3: 0x818cf8,
l2: 0x60a5fa,
storage: 0x34d399,
access: 0xc084fc,
vpn: 0xc084fc,
wireless: 0xfb7185,
};
// 相对时间格式化
function formatRelativeTime(timestamp: number): string {
if (!timestamp) return '未知';
const diff = Date.now() - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 30) return '刚刚';
if (seconds < 60) return `${seconds}秒前`;
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
const date = new Date(timestamp);
return `${date.getMonth() + 1}-${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
// 扩展 Host 的设备类型信息
function classifyHostType(host: Host): string {
const distro = (host.distro || '').toLowerCase();
const name = (host.name || '').toLowerCase();
const label = (host.label || '').toLowerCase();
const combined = `${distro} ${name} ${label}`;
if (/h3c|huawei|vrp|comware|cisco|switch/.test(combined)) return 'switch';
if (/router|路由/.test(combined)) return 'router';
if (/firewall|防火墙|fw/.test(combined)) return 'firewall';
if (/storage|存储|nas|san/.test(combined)) return 'storage';
if (/workstation|pc|桌面/.test(combined)) return 'workstation';
return 'server';
}
function classifyHostStatus(host: Host): string {
// 简化:有 SSH 连接记录就当 normal
if (!host) return 'offline';
return 'normal';
}
// 自动生成链路
interface Link {
id: string;
source: string;
target: string;
type: string;
}
function autoGenerateLinks(
devices: { id: string; type: string; name: string }[]
): Link[] {
const links: Link[] = [];
const coreSwitches = devices.filter(
(d) => d.type === 'switch' && /核心|core/i.test(d.name)
);
const accessSwitches = devices.filter(
(d) => d.type === 'switch' && !/核心|core/i.test(d.name)
);
const firewalls = devices.filter((d) => d.type === 'firewall');
const routers = devices.filter((d) => d.type === 'router');
const servers = devices.filter((d) => d.type === 'server');
let linkId = 0;
const push = (source: string, target: string, type: string) => {
links.push({ id: `link-${linkId++}`, source, target, type });
};
// 防火墙 → 路由/核心交换机
firewalls.forEach((f) => {
routers.forEach((r) => push(f.id, r.id, 'wan'));
if (coreSwitches.length === 0) {
accessSwitches.slice(0, 1).forEach((a) => push(f.id, a.id, 'wan'));
}
});
// 路由 → 核心交换机
routers.forEach((r) => {
coreSwitches.forEach((s) => push(r.id, s.id, 'l3'));
});
// 核心交换机 → 接入交换机
coreSwitches.forEach((c) => {
accessSwitches.forEach((a) => push(c.id, a.id, 'l2'));
});
// 接入交换机 → 服务器(每个交换机连 1-3 台服务器)
const serversPerSwitch = Math.max(1, Math.min(3, Math.ceil(servers.length / Math.max(accessSwitches.length, 1))));
accessSwitches.forEach((a, si) => {
for (let i = 0; i < serversPerSwitch; i++) {
const idx = (si * serversPerSwitch + i) % Math.max(servers.length, 1);
if (servers[idx]) push(a.id, servers[idx].id, 'access');
}
});
// 默认不生成连线,让用户手动添加
return [];
}
// 设备 3D 模型工厂(使用精细模型)
function createDeviceMesh(type: string, status: string): THREE.Group {
// 类型映射
const typeMap: Record<string, DeviceType> = {
server: 'server',
switch: 'switch',
router: 'router',
firewall: 'firewall',
storage: 'storage',
workstation: 'workstation',
h3c: 'switch',
huawei: 'switch',
};
const deviceType = typeMap[type] || 'server';
const deviceStatus: DeviceStatus = (status as DeviceStatus) || 'normal';
const group = createDeviceModel(deviceType, deviceStatus);
// 关键设备加发光点
const color = statusColors[status] ?? 0x10b981;
if (status === 'critical' || status === 'warning') {
const halo = new THREE.PointLight(color, status === 'critical' ? 1.5 : 0.8, 10);
halo.position.set(0, 0, 0);
group.add(halo);
}
// 双面材质兜底
group.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (mesh.isMesh) {
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
mats.forEach((m) => {
if (m) (m as THREE.Material).side = THREE.DoubleSide;
});
}
});
return group;
}
export function MonitoringTopologyView({ hosts, keys }: Props) {
// 使用真实数据 hook
const monitoring = useMonitoringData(hosts, keys);
const containerRef = useRef<HTMLDivElement | null>(null);
const chartTypeRef = useRef<HTMLDivElement | null>(null);
const chartTrendRef = useRef<HTMLDivElement | null>(null);
const [selectedDevice, setSelectedDevice] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
const [legend, setLegend] = useState(true);
// Three.js 全局引用
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const labelRendererRef = useRef<CSS2DRenderer | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const nodesRef = useRef<THREE.Group[]>([]);
const edgesRef = useRef<THREE.Line[]>([]);
const labelsRef = useRef<HTMLDivElement[]>([]);
const deviceMapRef = useRef<Map<string, any>>(new Map());
const raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
const pointerRef = useRef<THREE.Vector2>(new THREE.Vector2());
const animIdRef = useRef<number>(0);
const chartsRef = useRef<echarts.ECharts[]>([]);
const transformControlsRef = useRef<any>(null);
// 自定义拖拽状态
const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<{ node: THREE.Group | null; offset: THREE.Vector3 }>({ node: null, offset: new THREE.Vector3() });
// 时钟
useEffect(() => {
const id = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
// 右键菜单状态
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; deviceId: string } | null>(null);
const [pendingLink, setPendingLink] = useState<{ sourceId: string; targetId: string } | null>(null);
// 用户创建的连线(从 localStorage 加载)
const [customLinks, setCustomLinks] = useState<Link[]>(() => {
try {
const saved = localStorage.getItem('monitoring-topology-links');
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});
// 连线模式使用 ref 避免闭包问题
const linkModeRef = useRef<{ sourceId: string } | null>(null);
const [linkModeUI, setLinkModeUI] = useState<{ sourceId: string } | null>(null);
const autoRotateBeforeLinkRef = useRef(false); // 记录连线前的自动旋转状态
const setLinkMode = (mode: { sourceId: string } | null) => {
// 如果进入连线模式,暂停自动旋转
if (mode && controlsRef.current) {
autoRotateBeforeLinkRef.current = controlsRef.current.autoRotate;
controlsRef.current.autoRotate = false;
}
// 如果退出连线模式,恢复自动旋转
if (!mode && controlsRef.current) {
controlsRef.current.autoRotate = autoRotateBeforeLinkRef.current;
}
linkModeRef.current = mode;
setLinkModeUI(mode);
};
// 连线模式切换时更新 canvas 光标
useEffect(() => {
if (rendererRef.current) {
rendererRef.current.domElement.style.cursor = linkModeUI ? 'crosshair' : 'grab';
}
}, [linkModeUI]);
// 保存连线到 localStorage
useEffect(() => {
localStorage.setItem('monitoring-topology-links', JSON.stringify(customLinks));
}, [customLinks]);
// 全屏:运行监控独占大屏(覆盖左侧菜单/顶部,ESC 退出)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
e.preventDefault();
exitFullscreen();
}
};
if (isFullscreen) {
document.addEventListener('keydown', onKeyDown);
// body 加 class,配合 CSS 让 monitoring-page 固定全屏
document.body.classList.add('monitoring-fullscreen-active');
// Electron 环境下同时让窗口也全屏
const nc = (window as any).netcatty;
if (nc?.windowFullscreen) {
nc.windowFullscreen(true).catch(() => {});
}
}
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.classList.remove('monitoring-fullscreen-active');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFullscreen]);
const enterFullscreen = useCallback(() => {
setIsFullscreen(true);
}, []);
const exitFullscreen = useCallback(() => {
setIsFullscreen(false);
const nc = (window as any).netcatty;
if (nc?.windowFullscreen) {
nc.windowFullscreen(false).catch(() => {});
}
}, []);
const toggleFullscreen = useCallback(() => {
if (isFullscreen) exitFullscreen();
else enterFullscreen();
}, [isFullscreen, enterFullscreen, exitFullscreen]);
// 全屏切换时,触发所有 ECharts 重新计算尺寸(延迟几帧等布局稳定)
useEffect(() => {
const doResize = () => {
chartsRef.current.forEach((c) => {
try { c.resize(); } catch { /* 忽略 */ }
});
};
// 多次触发确保动画结束后尺寸正确
requestAnimationFrame(doResize);
setTimeout(doResize, 100);
setTimeout(doResize, 300);
setTimeout(doResize, 600);
}, [isFullscreen]);
// 设备列表 + 自动链路(数据来自巡检结果)
const devices = useMemo(() => {
return hosts.map((h) => {
const type = classifyHostType(h);
const online = monitoring.onlineMap[h.id] ?? true;
const cpuUsage = monitoring.cpuMap[h.id] ?? 0;
const memoryUsage = monitoring.memMap[h.id] ?? 0;
const lastUpdate = monitoring.timestampMap[h.id] ?? 0;
// 动态状态:根据 CPU/内存阈值 + 在线状态计算
let status: string;
if (!online) {
status = 'offline';
} else if (cpuUsage > 90 || memoryUsage > 95) {
status = 'critical';
} else if (cpuUsage > 75 || memoryUsage > 80) {
status = 'warning';
} else {
status = 'normal';
}
return {
id: h.id,
name: h.label || h.name || h.id,
ip: h.hostname,
type,
status,
model: '',
location: '',
online,
cpuUsage,
memoryUsage,
networkThroughput: 0,
lastUpdate,
};
});
}, [hosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap]);
// 合并自动生成链路和用户自定义链路
const links = useMemo(() => {
const autoLinks = autoGenerateLinks(devices);
return [...autoLinks, ...customLinks];
}, [devices, customLinks]);
// 统计
const stats = useMemo(() => {
const d = devices;
return {
total: d.length,
online: d.filter((x) => x.online && x.status === 'normal').length,
offline: d.filter((x) => !x.online || x.status === 'offline').length,
warning: d.filter((x) => x.status === 'warning').length,
critical: d.filter((x) => x.status === 'critical').length,
linkCount: links.length,
};
}, [devices, links]);
// 设备类型分布
const typeDistribution = useMemo(() => {
const counts: Record<string, number> = {
server: 0,
switch: 0,
router: 0,
firewall: 0,
storage: 0,
workstation: 0,
};
devices.forEach((d) => {
counts[d.type] = (counts[d.type] || 0) + 1;
});
return counts;
}, [devices]);
// Top5 列表
// CPU 负载列表(有 CPU 数据的设备,从高到低)
const cpuList = useMemo(
() =>
[...devices]
.filter((d) => d.cpuUsage > 0)
.sort((a, b) => b.cpuUsage - a.cpuUsage),
[devices]
);
// 内存负载列表(有内存数据的设备,从高到低)
const memoryList = useMemo(
() =>
[...devices]
.filter((d) => d.memoryUsage > 0)
.sort((a, b) => b.memoryUsage - a.memoryUsage),
[devices]
);
// 离线设备列表
const offlineDevices = useMemo(
() => devices.filter((d) => d.status === 'offline'),
[devices]
);
// 最新数据更新时间
const lastDataUpdate = useMemo(() => {
const timestamps = Object.values(monitoring.timestampMap);
if (timestamps.length === 0) return 0;
return Math.max(...timestamps);
}, [monitoring.timestampMap]);
// 活动告警(列表显示,只取前 8 条最新的)
const activeAlarms = useMemo(() => {
const order = ['critical', 'major', 'warning', 'minor', 'info'];
return [...monitoring.alerts]
.sort((a, b) => order.indexOf(a.level) - order.indexOf(b.level))
.slice(0, 8);
}, [monitoring.alerts]);
// 告警计数(用完整告警列表计算,确保统计准确)
const alarmCounts = useMemo(() => {
let critical = 0;
let warning = 0;
let info = 0;
for (const a of monitoring.alerts) {
if (a.level === 'critical') critical++;
else if (a.level === 'warning') warning++;
else info++;
}
return { critical, warning, info, total: monitoring.alerts.length };
}, [monitoring.alerts]);
// 巡检任务状态
const taskStats = monitoring.taskStats;
// ========== 详情面板状态 ==========
const [detailPanel, setDetailPanel] = useState<{
open: boolean;
type: PanelType;
title: string;
filter?: string;
deviceId?: string;
}>({ open: false, type: 'deviceList', title: '' });
const closeDetailPanel = useCallback(() => {
setDetailPanel((p) => ({ ...p, open: false }));
}, []);
const openDeviceList = useCallback((title: string, filter: string) => {
setDetailPanel({ open: true, type: 'deviceList', title, filter });
}, []);
const openAlertList = useCallback((title: string, level?: string) => {
setDetailPanel({ open: true, type: 'alertList', title, filter: level });
}, []);
const openDeviceDetail = useCallback((deviceId: string) => {
setDetailPanel({ open: true, type: 'deviceDetail', title: '设备详情', deviceId });
}, []);
const openTaskList = useCallback((title: string, status: string) => {
setDetailPanel({ open: true, type: 'taskList', title, filter: status });
}, []);
// 详情面板 - 设备列表数据
const panelDeviceList = useMemo(() => {
if (detailPanel.type !== 'deviceList' || !detailPanel.open) return [];
const f = detailPanel.filter;
let list = devices;
if (f === 'online') list = devices.filter((d) => d.status === 'normal');
else if (f === 'offline') list = devices.filter((d) => d.status === 'offline');
else if (f === 'warning') list = devices.filter((d) => d.status === 'warning');
else if (f === 'critical') list = devices.filter((d) => d.status === 'critical');
else if (f === 'all') list = devices;
return list.map((d) => ({
id: d.id,
name: d.name,
ip: d.ip,
type: d.type,
status: d.status as 'online' | 'offline' | 'warning' | 'critical',
cpu: d.cpuUsage,
memory: d.memoryUsage,
lastUpdate: d.lastUpdate,
}));
}, [detailPanel, devices]);
// 详情面板 - 告警列表数据
const panelAlertList = useMemo(() => {
if (detailPanel.type !== 'alertList' || !detailPanel.open) return [];
let list = monitoring.alerts;
if (detailPanel.filter && detailPanel.filter !== 'all') {
list = list.filter((a) => a.level === detailPanel.filter);
}
return list.map((a) => ({
id: a.id,
deviceId: a.deviceId,
deviceName: a.deviceName,
level: a.level as 'critical' | 'warning' | 'info',
message: a.message,
time: a.time,
metric: a.metric,
metricValue: a.metricValue,
threshold: a.threshold,
}));
}, [detailPanel, monitoring.alerts]);
// 详情面板 - 单设备详情
const panelDeviceDetail = useMemo(() => {
if (detailPanel.type !== 'deviceDetail' || !detailPanel.open || !detailPanel.deviceId) return undefined;
const d = devices.find((x) => x.id === detailPanel.deviceId);
if (!d) return undefined;
const deviceAlerts = monitoring.alerts
.filter((a) => a.deviceId === d.id)
.slice(0, 10)
.map((a) => ({
id: a.id,
level: a.level,
message: a.message,
time: a.time,
}));
return {
id: d.id,
name: d.name,
ip: d.ip,
type: d.type,
status: d.status as 'online' | 'offline' | 'warning' | 'critical',
cpu: d.cpuUsage,
memory: d.memoryUsage,
lastUpdate: d.lastUpdate,
alerts: deviceAlerts,
};
}, [detailPanel, devices, monitoring.alerts]);
// 详情面板 - 任务列表数据
const panelTaskList = useMemo(() => {
if (detailPanel.type !== 'taskList' || !detailPanel.open) return [];
const filter = detailPanel.filter as 'running' | 'success' | 'failed' | 'pending' | undefined;
if (!filter) return monitoring.taskDevices;
let filtered;
if (filter === 'running') {
// 执行中 = 所有启用了巡检的设备
filtered = monitoring.taskDevices.filter((d) => d.inspectionEnabled);
} else {
filtered = monitoring.taskDevices.filter((d) => d.status === filter);
}
return filtered;
}, [detailPanel, monitoring.taskDevices]);
// ===== Three.js 场景初始化 =====
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
// 清理旧资源
nodesRef.current.forEach((g) => {
g.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
(obj as THREE.Mesh).geometry?.dispose();
const mat = (obj as THREE.Mesh).material;
if (Array.isArray(mat)) mat.forEach((m) => m?.dispose());
else (mat as THREE.Material)?.dispose();
}
});
});
edgesRef.current.forEach((e) => {
e.geometry?.dispose();
(e.material as THREE.Material)?.dispose();
});
labelsRef.current.forEach((l) => l.remove());
const scene = new THREE.Scene();
// 深色运维蓝背景 + 雾气
scene.background = new THREE.Color(0x0a1628);
scene.fog = new THREE.Fog(0x0a1628, 100, 250);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(
60,
container.clientWidth / container.clientHeight,
0.1,
500
);
camera.position.set(0, 40, 60); // 调整位置以看到所有设备
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setClearColor(0x000000, 0);
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
renderer.localClippingEnabled = true;
renderer.domElement.style.cursor = 'grab';
// CSS2D 标签渲染器
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(container.clientWidth, container.clientHeight);
const labelDom = labelRenderer.domElement;
labelDom.style.position = 'absolute';
labelDom.style.top = '0';
labelDom.style.left = '0';
labelDom.style.width = '100%';
labelDom.style.height = '100%';
labelDom.style.pointerEvents = 'none';
labelDom.style.zIndex = '10';
container.appendChild(labelDom);
labelRendererRef.current = labelRenderer;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0); // 目标设为原点,能看到设备区域
controls.update();
controls.minDistance = 30;
controls.maxDistance = 150;
controls.maxPolarAngle = Math.PI / 2 - 0.1;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.4;
controlsRef.current = controls;
// TransformControls 拖拽(不添加到 scene,使用 ref 引用)
const transformControls = new TransformControls(camera, renderer.domElement);
transformControls.setMode('translate');
transformControls.setSize(0.8);
transformControlsRef.current = transformControls;
// 拖拽时不更新 OrbitControls
transformControls.addEventListener('dragging-changed', (event) => {
controls.enabled = !event.value;
});
// 拖拽时禁用 autoRotate
transformControls.addEventListener('dragging-changed', (event) => {
controls.autoRotate = !event.value;
});
// 自定义拖拽变量
let isDraggingDevice = false;
let draggingNode: THREE.Group | null = null;
let dragPlane = new THREE.Plane();
let dragOffset = new THREE.Vector3();
const mouseDownPosRef = { x: 0, y: 0 }; // 使用对象引用避免闭包问题
// 灯光
scene.add(new THREE.AmbientLight(0x404060, 0.6));
const keyLight = new THREE.DirectionalLight(0x60a5fa, 0.8);
keyLight.position.set(30, 50, 30);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x22d3ee, 0.4);
rimLight.position.set(-30, 20, -30);
scene.add(rimLight);
// ===== 地图纹理 + 双网格(ITYUNWEI 原版)=====
const gridSize = 320;
const gridDiv = 40;
let mapW = gridSize;
let mapH = gridSize;
// 裁剪平面工厂
const makeClipPlanes = (w: number, h: number) => [
new THREE.Plane(new THREE.Vector3(1, 0, 0), w / 2),
new THREE.Plane(new THREE.Vector3(-1, 0, 0), w / 2),
new THREE.Plane(new THREE.Vector3(0, 0, 1), h / 2),
new THREE.Plane(new THREE.Vector3(0, 0, -1), h / 2),
];
// 地图外网格(深蓝)
const gridOutside = new THREE.GridHelper(gridSize, gridDiv, 0x3d6aa0, 0x1e3a60);
gridOutside.position.y = -0.45;
const outsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
(gridOutside.material as THREE.Material).clippingPlanes = outsidePlanes;
scene.add(gridOutside);
// 地图上网格(极浅半透明)
const gridOnMap = new THREE.GridHelper(gridSize, gridDiv, 0x0d1f30, 0x060f1a);
gridOnMap.position.y = -0.44;
const onMapMat = gridOnMap.material as THREE.MeshBasicMaterial;
onMapMat.transparent = true;
onMapMat.opacity = 0.18;
scene.add(gridOnMap);
// Glow ring
const ringGeo = new THREE.RingGeometry(25, 30, 64);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x60a5fa,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -0.2;
scene.add(ring);
// SkyDome - 深色运维蓝渐变
const skyGeo = new THREE.SphereGeometry(300, 48, 32);
skyGeo.scale(-1, 1, 1);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
uTop: { value: new THREE.Color('#020617') },
uMid: { value: new THREE.Color('#0c4a6e') },
uSky: { value: new THREE.Color('#0369a1') },
uHorizon: { value: new THREE.Color('#0891b2') },
uBottom: { value: new THREE.Color('#0f172a') },
},
vertexShader: `
varying vec3 vWorldPos;
void main(){ vWorldPos=(modelMatrix*vec4(position,1.0)).xyz; gl_Position=projectionMatrix*viewMatrix*modelMatrix*vec4(position,1.0); }
`,
fragmentShader: `
varying vec3 vWorldPos;
uniform vec3 uTop, uMid, uSky, uHorizon, uBottom;
void main(){
float h = normalize(vWorldPos).y;
vec3 col;
if(h > 0.5) col = mix(uMid, uTop, smoothstep(0.5, 1.0, h));
else if(h > 0.15) col = mix(uSky, uMid, smoothstep(0.15, 0.5, h));
else if(h > 0.02) col = mix(uHorizon, uSky, smoothstep(0.02, 0.15, h));
else if(h > -0.05) col = mix(uBottom, uHorizon, smoothstep(-0.05, 0.02, h));
else col = uBottom;
gl_FragColor = vec4(col, 1.0);
}
`,
side: THREE.BackSide,
depthWrite: false,
});
const sky = new THREE.Mesh(skyGeo, skyMat);
sky.name = 'SkyDome';
scene.add(sky);
// ===== ditu.png 地图纹理 + 边缘发光晕 =====
const texLoader = new THREE.TextureLoader();
texLoader.setCrossOrigin('anonymous');
texLoader.load(
'/ditu.png',
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
const mapAspect = texture.image.width / texture.image.height;
mapW = gridSize;
mapH = gridSize / mapAspect;
// 地图发光底层(比地图稍大,形成发光边缘)
const glowW = mapW + 6;
const glowH = mapH + 6;
const glowGeo = new THREE.PlaneGeometry(glowW, glowH);
const glowMat = new THREE.ShaderMaterial({
uniforms: {
uMap: { value: texture },
uColor: { value: new THREE.Color('#38bdf8') },
uFade: { value: 0.12 },
},
vertexShader: `
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D uMap;
uniform vec3 uColor;
uniform float uFade;
void main(){
vec4 c = texture2D(uMap, vUv);
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
float edge = min(dx, dy);
float glow = 1.0 - smoothstep(0.0, uFade, edge);
gl_FragColor = vec4(uColor, glow * 0.5);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
});
const glowPlane = new THREE.Mesh(glowGeo, glowMat);
glowPlane.rotation.x = -Math.PI / 2;
glowPlane.position.y = -0.58;
scene.add(glowPlane);
const mapGeo = new THREE.PlaneGeometry(mapW, mapH);
const mapMat = new THREE.ShaderMaterial({
uniforms: {
uMap: { value: texture },
uOpacity: { value: 0.85 },
uFade: { value: 0.08 },
},
vertexShader: `
varying vec2 vUv;
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D uMap;
uniform float uOpacity;
uniform float uFade;
void main(){
vec4 c = texture2D(uMap, vUv);
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
float edge = min(dx, dy);
float a = smoothstep(0.0, uFade, edge);
gl_FragColor = vec4(c.rgb, c.a * a * uOpacity);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
});
const mapPlane = new THREE.Mesh(mapGeo, mapMat);
mapPlane.rotation.x = -Math.PI / 2;
mapPlane.position.y = -0.6;
scene.add(mapPlane);
// 地图加载完更新网格裁剪平面
const newOnMapPlanes = makeClipPlanes(mapW, mapH);
;(gridOnMap.material as THREE.Material).clippingPlanes = newOnMapPlanes;
const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
;(gridOutside.material as THREE.Material).clippingPlanes = newOutsidePlanes;
},
undefined,
() => {
// 地图加载失败,静默跳过
console.warn('[Monitoring] ditu.png load failed, skipping map texture');
}
);
// 设备布局(分层集群)
deviceMapRef.current = new Map(devices.map((d) => [d.id, d]));
const positions: Record<string, THREE.Vector3> = {};
// 从 localStorage 加载保存的位置
const savedPositions = (() => {
try {
const saved = localStorage.getItem('monitoring-topology-positions');
return saved ? JSON.parse(saved) : null;
} catch {
return null;
}
})();
const coreSwitches = devices.filter((d) => d.type === 'switch' && /核心|core/i.test(d.name));
const accessSwitches = devices.filter((d) => d.type === 'switch' && !/核心|core/i.test(d.name));
const routers = devices.filter((d) => d.type === 'router');
const firewalls = devices.filter((d) => d.type === 'firewall');
const servers = devices.filter((d) => d.type === 'server');
const rest = devices.filter((d) => !positions[d.id]);
// 如果有保存的位置,直接使用;否则使用默认布局
if (savedPositions) {
devices.forEach((d) => {
if (savedPositions[d.id]) {
positions[d.id] = new THREE.Vector3(
savedPositions[d.id].x,
savedPositions[d.id].y,
savedPositions[d.id].z
);
}
});
} else {
// 默认分层布局
firewalls.forEach((d, i) => {
positions[d.id] = new THREE.Vector3((i - firewalls.length / 2) * 10, 8, -25);
});
routers.forEach((d, i) => {
positions[d.id] = new THREE.Vector3((i - routers.length / 2) * 10, 7, -15);
});
coreSwitches.forEach((d, i) => {
positions[d.id] = new THREE.Vector3((i - coreSwitches.length / 2) * 12, 6, 0);
});
accessSwitches.forEach((d, i) => {
positions[d.id] = new THREE.Vector3(
Math.cos((i / Math.max(accessSwitches.length, 1)) * Math.PI * 2) * 25 +
(Math.random() - 0.5) * 6,
4,
Math.sin((i / Math.max(accessSwitches.length, 1)) * Math.PI * 2) * 25
);
});
servers.forEach((d, i) => {
positions[d.id] = new THREE.Vector3(
Math.cos((i / Math.max(servers.length, 1)) * Math.PI * 2 + 0.3) * 40 +
(Math.random() - 0.5) * 8,
2 + Math.random() * 2,
Math.sin((i / Math.max(servers.length, 1)) * Math.PI * 2 + 0.3) * 40
);
});
rest.forEach((d, i) => {
positions[d.id] = new THREE.Vector3(
Math.cos((i / Math.max(rest.length, 1)) * Math.PI * 2 + 1) * 50,
1,
Math.sin((i / Math.max(rest.length, 1)) * Math.PI * 2 + 1) * 50 + 20
);
});
}
// 创建设备节点
const nodes: THREE.Group[] = [];
for (const device of devices) {
const pos = positions[device.id] || new THREE.Vector3(0, 0, 0);
const group = createDeviceMesh(device.type, device.status);
group.position.copy(pos);
group.userData = {
deviceId: device.id,
baseScale: 1,
isCritical: device.status === 'critical',
currentStatus: device.status,
labelDiv: null as HTMLDivElement | null,
};
scene.add(group);
nodes.push(group);
// 名字 label(CSS2DObject,设备上方)— 背景色随设备状态变化
const labelDiv = document.createElement('div');
labelDiv.className = 'three-label';
labelDiv.textContent = device.name;
labelDiv.dataset.deviceId = device.id;
labelDiv.dataset.status = device.status;
const labelStyle = getLabelStyle(device.status);
Object.assign(labelDiv.style, {
color: '#ffffff',
fontSize: '13px',
fontWeight: 'bold',
padding: '3px 10px',
background: labelStyle.background,
border: labelStyle.border,
borderRadius: '6px',
pointerEvents: 'none',
whiteSpace: 'nowrap',
transform: 'translate(-50%, -100%)',
fontFamily: 'Inter, "Microsoft YaHei", "PingFang SC", sans-serif',
letterSpacing: '0.3px',
textShadow: '0 1px 3px rgba(0,0,0,0.5)',
boxShadow: `0 4px 16px ${labelStyle.glow}`,
marginTop: '-8px',
});
const labelObj = new CSS2DObject(labelDiv);
labelObj.position.set(0, 3, 0); // 设备上方 3 单位
group.add(labelObj);
group.userData.labelDiv = labelDiv;
labelsRef.current.push(labelDiv);
}
nodesRef.current = nodes;
// 创建链路
const edges: THREE.Line[] = [];
for (const link of links) {
const a = positions[link.source];
const b = positions[link.target];
if (!a || !b) continue;
const points = [a.clone(), b.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const color = edgeColorMap[link.type] || 0x60a5fa;
const mat = new THREE.LineDashedMaterial({
color,
transparent: true,
opacity: 0.85,
dashSize: 1.5,
gapSize: 0.8,
});
const line = new THREE.Line(geo, mat);
line.userData = { sourceId: link.source, targetId: link.target };
line.computeLineDistances();
scene.add(line);
edges.push(line);
}
edgesRef.current = edges;
// Raycaster 点击和拖拽
const raycaster = new THREE.Raycaster();
raycaster.params.Line = { threshold: 2.5 };
raycasterRef.current = raycaster;
const getDeviceNodeFromIntersect = (obj: THREE.Object3D): THREE.Object3D | null => {
let cur: THREE.Object3D | null = obj;
while (cur && !cur.userData?.deviceId) cur = cur.parent;
return cur;
};
const onPointerDown = (event: MouseEvent) => {
if (event.button !== 0) return; // 只处理左键
// 连线模式下不处理拖拽
if (linkModeRef.current) {
// 保存点击位置(连线模式下 onPointerDown 会提前返回)
mouseDownPosRef.x = event.clientX;
mouseDownPosRef.y = event.clientY;
return;
}
const rect = renderer.domElement.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointerRef.current.set(x, y);
raycaster.setFromCamera(pointerRef.current, camera);
const intersects = raycaster.intersectObjects(nodes, true);
// 保存点击位置(连线模式也需要记录)
mouseDownPosRef.x = event.clientX;
mouseDownPosRef.y = event.clientY;
if (intersects.length > 0) {
const cur = getDeviceNodeFromIntersect(intersects[0].object);
if (cur) {
isDraggingDevice = true;
draggingNode = cur as THREE.Group;
controls.enabled = false; // 禁用 OrbitControls
controls.autoRotate = false;
// 设置拖拽平面(垂直于相机方向)
const normal = new THREE.Vector3();
camera.getWorldDirection(normal);
dragPlane.setFromNormalAndCoplanarPoint(normal, draggingNode.position);
// 计算鼠标在平面上的偏移
const intersection = new THREE.Vector3();
raycaster.ray.intersectPlane(dragPlane, intersection);
dragOffset.copy(draggingNode.position).sub(intersection);
setIsDragging(true);
console.log('[拖拽] 开始拖拽设备:', draggingNode.userData.deviceId);
}
}
};
const onPointerMove = (event: MouseEvent) => {
if (!isDraggingDevice || !draggingNode) return;
const rect = renderer.domElement.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointerRef.current.set(x, y);
raycaster.setFromCamera(pointerRef.current, camera);
const intersection = new THREE.Vector3();
if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
draggingNode.position.copy(intersection.add(dragOffset));
}
};
const onPointerUp = (event: MouseEvent) => {
if (isDraggingDevice && draggingNode) {
console.log('[拖拽] 结束拖拽,新位置:', draggingNode.position.x, draggingNode.position.y, draggingNode.position.z);
isDraggingDevice = false;
draggingNode = null;
controls.enabled = true;
setIsDragging(false);
}
};
const onPointerClick = (event: MouseEvent) => {
// 检查是否是点击(而不是拖拽)
const dx = event.clientX - mouseDownPosRef.x;
const dy = event.clientY - mouseDownPosRef.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 30) return; // 移动距离太大(阈值30),跳过点击
const rect = renderer.domElement.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointerRef.current.set(x, y);
raycaster.setFromCamera(pointerRef.current, camera);
const intersects = raycaster.intersectObjects(nodes, true);
if (intersects.length > 0) {
const cur = getDeviceNodeFromIntersect(intersects[0].object);
if (cur) {
const id = cur.userData.deviceId as string;
const dev = deviceMapRef.current.get(id);
// 连线模式
if (linkModeRef.current) {
if (!linkModeRef.current.sourceId) {
// 选择源设备
setLinkMode({ sourceId: id });
console.log('[连线] 已选择源设备:', dev?.name);
} else if (linkModeRef.current.sourceId !== id) {
// 选择目标设备,显示链路类型选择
setPendingLink({ sourceId: linkModeRef.current.sourceId, targetId: id });
setLinkMode(null);
}
} else {
// 普通模式:选中设备并打开详情
setSelectedDevice(dev || null);
openDeviceDetail(id);
}
}
} else {
// 连线模式下点击空白处,退出连线模式
if (linkModeRef.current) {
setLinkMode(null);
} else {
setSelectedDevice(null);
}
}
};
// 点击空白处取消选中
const onBackgroundClick = (event: MouseEvent) => {
if (isDraggingDevice) return;
const rect = renderer.domElement.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointerRef.current.set(x, y);
raycaster.setFromCamera(pointerRef.current, camera);
const intersects = raycaster.intersectObjects(nodes, true);
if (intersects.length === 0 && !linkModeUI) {
setSelectedDevice(null);
}
};
// 右键菜单:创建连线
const onContextMenu = (event: MouseEvent) => {
event.preventDefault();
const rect = renderer.domElement.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointerRef.current.set(x, y);
raycaster.setFromCamera(pointerRef.current, camera);
const intersects = raycaster.intersectObjects(nodes, true);
if (intersects.length > 0) {
const obj = intersects[0].object;
let cur: THREE.Object3D | null = obj;
while (cur && !cur.userData?.deviceId) cur = cur.parent;
if (cur) {
const id = cur.userData.deviceId as string;
setContextMenu({ x: event.clientX, y: event.clientY, deviceId: id });
}
} else {
setContextMenu(null);
}
};
renderer.domElement.addEventListener('contextmenu', onContextMenu);
renderer.domElement.addEventListener('mousedown', onPointerDown);
renderer.domElement.addEventListener('mousemove', onPointerMove);
renderer.domElement.addEventListener('mouseup', onPointerUp);
renderer.domElement.addEventListener('click', onPointerClick);
renderer.domElement.addEventListener('click', onBackgroundClick);
// Resize
const onResize = () => {
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
labelRenderer.setSize(w, h);
chartsRef.current.forEach((c) => c.resize());
};
const resizeObs = new ResizeObserver(onResize);
resizeObs.observe(container);
// 动画循环
const clock = new THREE.Clock();
const animate = () => {
animIdRef.current = requestAnimationFrame(animate);
const t = Date.now() * 0.001;
const elapsed = clock.getElapsedTime();
// Critical 节点呼吸灯 + 选中节点高亮 + 运行时状态同步
nodes.forEach((n, i) => {
const deviceId = n.userData.deviceId as string;
const isSelected = selectedDevice?.id === deviceId;
// 1) 运行时状态同步:从 deviceMapRef 拿最新 status
const latestDev = deviceMapRef.current.get(deviceId);
const latestStatus: string | undefined = latestDev?.status;
const prevStatus: string = n.userData.currentStatus;
const statusChanged = latestStatus && latestStatus !== prevStatus;
if (statusChanged) {
n.userData.currentStatus = latestStatus;
n.userData.isCritical = latestStatus === 'critical';
// 同步标签颜色
const labelDiv = n.userData.labelDiv as HTMLDivElement | null;
if (labelDiv) {
const s = getLabelStyle(latestStatus);
labelDiv.style.background = s.background;
labelDiv.style.border = s.border;
labelDiv.style.boxShadow = `0 4px 16px ${s.glow}`;
labelDiv.dataset.status = latestStatus;
}
// 同步模型 body emissive 颜色(所有状态都更新)
const emissiveHex = statusColors[latestStatus] ?? statusColors.offline;
n.traverse((obj) => {
if (
obj instanceof THREE.Mesh &&
obj.userData?.isBody &&
'emissive' in (obj.material as any)
) {
const mat = obj.material as THREE.MeshStandardMaterial;
mat.emissive.setHex(emissiveHex);
// 根据状态设置基础发光强度
mat.emissiveIntensity =
latestStatus === 'normal' ? 0.5 :
latestStatus === 'warning' ? 0.7 :
latestStatus === 'critical' ? 1.0 : 0.2;
}
});
}
// 2) 呼吸灯/脉冲效果(critical 或选中)
const shouldPulse = n.userData.isCritical || isSelected;
if (shouldPulse) {
let intensity: number;
if (isSelected) {
intensity = 0.3 + Math.sin(t * 4) * 0.2;
} else if (n.userData.isCritical) {
intensity = 0.5 + Math.sin(t * 3 + i) * 0.3;
}
n.traverse((obj) => {
if (
obj instanceof THREE.Mesh &&
obj.userData?.isBody &&
'emissiveIntensity' in (obj.material as any)
) {
const mat = obj.material as THREE.MeshStandardMaterial;
if (isSelected) {
mat.emissive.setHex(0x38bdf8);
}
mat.emissiveIntensity = intensity;
}
});
}
// 3) 轻微浮动
const baseY = positions[n.userData.deviceId]?.y ?? 0;
n.position.y = baseY + Math.sin(elapsed * 0.5 + i) * 0.15;
});
// 虚线流动 + 连线端点跟随设备位置
edges.forEach((line, i) => {
const sourceId = line.userData.sourceId;
const targetId = line.userData.targetId;
const sourceNode = nodes.find((n) => n.userData.deviceId === sourceId);
const targetNode = nodes.find((n) => n.userData.deviceId === targetId);
if (sourceNode && targetNode) {
const posAttr = line.geometry.attributes.position;
posAttr.setXYZ(0, sourceNode.position.x, sourceNode.position.y, sourceNode.position.z);
posAttr.setXYZ(1, targetNode.position.x, targetNode.position.y, targetNode.position.z);
posAttr.needsUpdate = true;
line.computeLineDistances();
line.geometry.computeBoundingSphere();
}
const mat = line.material as THREE.LineDashedMaterial;
mat.dashOffset = -((t * 1.2 + i * 0.05) % (mat.dashSize + mat.gapSize));
});
controls.update();
transformControls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
};
animate();
setLoading(false);
return () => {
cancelAnimationFrame(animIdRef.current);
resizeObs.disconnect();
renderer.domElement.removeEventListener('contextmenu', onContextMenu);
renderer.domElement.removeEventListener('mousedown', onPointerDown);
renderer.domElement.removeEventListener('mousemove', onPointerMove);
renderer.domElement.removeEventListener('mouseup', onPointerUp);
renderer.domElement.removeEventListener('click', onPointerClick);
renderer.domElement.removeEventListener('click', onBackgroundClick);
// 保存设备位置到 localStorage
try {
const posData: Record<string, { x: number; y: number; z: number }> = {};
nodes.forEach((n) => {
const id = n.userData.deviceId;
if (id) {
posData[id] = {
x: n.position.x,
y: n.position.y,
z: n.position.z,
};
}
});
localStorage.setItem('monitoring-topology-positions', JSON.stringify(posData));
} catch (e) {
console.warn('[Monitoring] Failed to save positions:', e);
}
nodes.forEach((g) => {
g.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
(obj as THREE.Mesh).geometry?.dispose();
const mat = (obj as THREE.Mesh).material;
if (Array.isArray(mat)) mat.forEach((m) => m?.dispose());
else (mat as THREE.Material)?.dispose();
}
});
});
edges.forEach((e) => {
e.geometry?.dispose();
(e.material as THREE.Material)?.dispose();
});
labelsRef.current.forEach((l) => l.remove());
labelsRef.current = [];
renderer.dispose();
if (renderer.domElement.parentNode === container) {
container.removeChild(renderer.domElement);
}
if (labelRenderer.domElement.parentNode === container) {
container.removeChild(labelRenderer.domElement);
}
chartsRef.current.forEach((c) => c.dispose());
chartsRef.current = [];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [devices.length, links.length]);
// 设备状态实时同步:每次 devices 变化更新 ref,动画循环读取最新 status
useEffect(() => {
deviceMapRef.current = new Map(devices.map((d) => [d.id, d]));
}, [devices]);
// ===== ECharts 初始化 =====
useEffect(() => {
// 等 DOM 挂载 + 数据就绪
const timer = setTimeout(() => {
chartsRef.current.forEach((c) => c.dispose());
chartsRef.current = [];
const fs = {
xs: 8,
sm: 10,
md: 12,
};
const commonGrid = { top: 24, left: 32, right: 10, bottom: 24, containLabel: true };
// 设备类型分布柱状图
if (chartTypeRef.current) {
const c = echarts.init(chartTypeRef.current, undefined, { renderer: 'canvas' });
chartsRef.current.push(c);
const names = ['服务器', '交换机', '路由器', '防火墙', '存储', '工作站'];
const data = [
typeDistribution.server,
typeDistribution.switch,
typeDistribution.router,
typeDistribution.firewall,
typeDistribution.storage,
typeDistribution.workstation,
];
c.setOption({
grid: commonGrid,
tooltip: {
trigger: 'axis',
backgroundColor: '#0f172a',
borderColor: '#1e293b',
textStyle: { color: '#e2e8f0', fontSize: fs.sm },
},
xAxis: {
type: 'category',
data: names,
axisLine: { lineStyle: { color: '#334155' } },
axisTick: { show: false },
axisLabel: { color: '#64748b', fontSize: fs.xs, interval: 0, rotate: 20 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } },
axisLabel: { color: '#64748b', fontSize: fs.sm },
minInterval: 1,
},
series: [
{
type: 'bar',
data,
barWidth: '45%',
label: {
show: true,
position: 'top',
color: '#38bdf8',
fontSize: fs.xs + 1,
fontWeight: 600,
},
itemStyle: {
borderRadius: [3, 3, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#0ea5e9' },
{ offset: 1, color: 'rgba(14,165,233,0.2)' },
]),
},
},
],
});
}
// 告警趋势(暂时用巡检历史生成)
if (chartTrendRef.current) {
const c = echarts.init(chartTrendRef.current, undefined, { renderer: 'canvas' });
chartsRef.current.push(c);
const trend = monitoring.alarmTrend;
c.setOption({
grid: commonGrid,
tooltip: {
trigger: 'axis',
backgroundColor: '#0f172a',
borderColor: '#1e293b',
textStyle: { color: '#e2e8f0', fontSize: fs.sm },
},
xAxis: {
type: 'category',
data: trend.map((t) => t.date),
axisLine: { lineStyle: { color: '#334155' } },
axisTick: { show: false },
axisLabel: { color: '#64748b', fontSize: fs.xs },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } },
axisLabel: { color: '#64748b', fontSize: fs.sm },
minInterval: 1,
},
series: [
{
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 5,
data: trend.map((t) => t.count),
lineStyle: { color: '#22d3ee', width: 2 },
itemStyle: { color: '#22d3ee' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(34,211,238,0.4)' },
{ offset: 1, color: 'rgba(34,211,238,0)' },
]),
},
},
],
});
}
// 多次 resize 确保撑满
const resizeAll = () => chartsRef.current.forEach((c) => c.resize());
requestAnimationFrame(resizeAll);
setTimeout(resizeAll, 100);
setTimeout(resizeAll, 300);
}, 80);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeDistribution, monitoring.alarmTrend]);
const resetView = useCallback(() => {
if (cameraRef.current && controlsRef.current) {
cameraRef.current.position.set(0, 50, 75);
controlsRef.current.target.set(0, -12, 0);
controlsRef.current.autoRotate = true;
controlsRef.current.update();
}
}, []);
const refreshData = useCallback(() => {
monitoring.refresh();
}, [monitoring]);
// 自动刷新间隔(秒)
const [autoRefreshInterval, setAutoRefreshInterval] = useState<number>(() => {
try {
const saved = localStorage.getItem('monitoring-auto-refresh');
return saved !== null ? parseInt(saved, 10) : 30;
} catch {
return 30;
}
});
const [showRefreshMenu, setShowRefreshMenu] = useState(false);
const refreshOptions = [
{ label: '关闭', value: 0 },
{ label: '10 秒', value: 10 },
{ label: '30 秒', value: 30 },
{ label: '1 分钟', value: 60 },
{ label: '2 分钟', value: 120 },
{ label: '5 分钟', value: 300 },
];
// 保存刷新间隔到 localStorage
useEffect(() => {
try {
localStorage.setItem('monitoring-auto-refresh', String(autoRefreshInterval));
} catch { /* 忽略 */ }
}, [autoRefreshInterval]);
// 自动刷新定时器
useEffect(() => {
if (autoRefreshInterval <= 0) return;
const id = setInterval(() => {
monitoring.refresh();
}, autoRefreshInterval * 1000);
return () => clearInterval(id);
}, [autoRefreshInterval, monitoring]);
return (
<>
{/* 3D 节点 label 样式 + 整页深色主题覆盖(ITYUNWEI 风格) */}
<style>{`
.monitoring-page {
--m-bg-dark: #111827;
--m-bg-card: #1e293b;
--m-bg-panel: #263449;
--m-bg-hover: #3d4f66;
--m-border: rgba(148, 163, 184, 0.15);
--m-border-light: rgba(148, 163, 184, 0.3);
--m-text-primary: #f1f5f9;
--m-text-secondary: #94a3b8;
--m-text-muted: #64748b;
--m-primary: #0ea5e9;
--m-primary-light: #38bdf8;
--m-accent: #22d3ee;
--m-success: #10b981;
--m-warning: #f59e0b;
--m-danger: #ef4444;
--m-offline: #6b7280;
}
.monitoring-page .text-muted-foreground { color: var(--m-text-muted) !important; }
.monitoring-page .text-foreground { color: var(--m-text-primary) !important; }
.monitoring-page .border-border\\/60 { border-color: var(--m-border) !important; }
.monitoring-page .bg-muted { background: rgba(148,163,184,0.15) !important; }
.monitoring-page .bg-muted\\/50 { background: rgba(148,163,184,0.08) !important; }
.monitoring-page .hover\:bg-slate-700:hover { background: #334155 !important; }
.monitoring-page .card { background: #1e293b !important; border-color: rgba(148,163,184,0.15) !important; }
.monitoring-page .border { border-color: rgba(148,163,184,0.15) !important; }
.monitoring-page { color: #f1f5f9; }
/* 全屏模式:monitoring-page 直接 fixed 撑满整个视口,盖住左侧菜单和顶部 */
body.monitoring-fullscreen-active .monitoring-page {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 9999 !important;
padding: 16px !important;
gap: 12px !important;
margin: 0 !important;
}
body.monitoring-fullscreen-active {
overflow: hidden !important;
}
.three-label {
position: absolute;
pointer-events: none;
z-index: 10;
font-size: 10px;
line-height: 1.1;
padding: 1px 6px;
background: rgba(0,0,0,0.55);
border: 1px solid rgba(148,163,184,0.2);
border-radius: 3px;
color: #e2e8f0;
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
backdrop-filter: blur(3px);
white-space: nowrap;
transform: translate(-50%, -150%);
font-family: Inter, "Microsoft YaHei", sans-serif;
letter-spacing: 0.3px;
font-weight: 500;
transition: opacity 0.2s ease;
user-select: none;
}
/* 连线模式下 canvas 光标 */
.monitoring-page.monitoring-canvas-link-mode {
cursor: crosshair !important;
}
.monitoring-page.monitoring-canvas-link-mode * {
cursor: crosshair !important;
}
`}</style>
<div
className={`relative overflow-hidden monitoring-page ${linkModeUI ? 'monitoring-canvas-link-mode' : ''} ${
isFullscreen
? 'fixed inset-0 z-[9999] p-4'
: 'h-full w-full p-[clamp(6px,1vw,12px)] gap-[clamp(6px,1vw,12px)]'
}`}
style={{
display: 'grid',
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: isFullscreen
? 'clamp(240px, 18vw, 380px) minmax(0, 1fr) clamp(240px, 18vw, 380px)'
: 'clamp(200px, 18vw, 320px) minmax(0, 1fr) clamp(200px, 18vw, 320px)',
background: '#111827',
gap: isFullscreen ? '12px' : undefined,
}}
>
<div
className="col-span-3 z-50 flex items-center justify-between gap-4 px-5 rounded-lg whitespace-nowrap"
style={{
background: 'rgba(30, 41, 59, 0.85)',
border: '1px solid rgba(148,163,184,0.15)',
}}
>
<div className="flex-1 min-w-0 flex items-center gap-3 text-xs overflow-hidden">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-muted-foreground">正常</span>
<span className="font-semibold text-emerald-400">{stats.online}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-muted-foreground">预警</span>
<span className="font-semibold text-amber-400">{stats.warning}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-500" />
<span className="text-muted-foreground">故障</span>
<span className="font-semibold text-red-400">{stats.critical}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-slate-500" />
<span className="text-muted-foreground">离线</span>
<span className="font-semibold text-slate-400">{stats.offline}</span>
</div>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-1.5 text-muted-foreground">
<Server className="w-3.5 h-3.5 text-cyan-400" />
<span>{stats.total} 台设备</span>
<span>·</span>
<span>{stats.linkCount} 链路</span>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-3">
{alarmCounts.total > 0 && (
<div
className="px-2 py-0.5 rounded animate-pulse flex items-center gap-1 text-xs cursor-pointer hover:opacity-80 transition-opacity"
style={{
background: 'rgba(239,68,68,0.15)',
color: '#f87171',
border: '1px solid rgba(239,68,68,0.3)',
}}
onClick={() => openAlertList('活动告警', 'all')}
>
⚠ {alarmCounts.total} 活动告警
</div>
)}
<div className="flex items-center gap-1.5 text-xs font-mono text-muted-foreground">
<Clock className="w-3.5 h-3.5 text-cyan-400" />
<span>数据更新于 {lastDataUpdate ? formatRelativeTime(lastDataUpdate) : '暂无数据'}</span>
</div>
<button
onClick={() => {
if (linkModeUI) {
setLinkMode(null);
} else {
setLinkMode({ sourceId: '' });
}
}}
className={`px-2 py-1 rounded text-xs flex items-center gap-1 transition-colors ${
linkModeUI
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/40'
: 'hover:bg-slate-700 text-muted-foreground'
}`}
title={linkModeUI ? '退出连线模式' : '连线模式'}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
{linkModeUI ? '退出连线' : '连线'}
</button>
<button
onClick={toggleFullscreen}
className="p-1.5 rounded hover:bg-slate-700 transition-colors"
title={isFullscreen ? '退出全屏 (ESC)' : '进入全屏'}
>
{isFullscreen ? (
<Minimize2 className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<Expand className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
</div>
</div>
{/* 左栏 */}
<aside
className="min-w-0 min-h-0 grid overflow-hidden w-full h-full gap-3"
style={{
gridTemplateRows: 'auto minmax(120px, 1fr) minmax(0,1fr) minmax(0,1fr)',
}}
>
{/* 设备概览 */}
<div className="card rounded-lg border p-3 flex-shrink-0" style={{ background: '#1e293b', border: '1px solid rgba(148,163,184,0.15)' }}>
<div className="flex items-center gap-2 mb-1.5">
<Server className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">设备运行概览</span>
</div>
<div className="grid grid-cols-2 gap-1.5 text-[11px]">
{[
{ label: '总数', value: stats.total, bg: 'bg-muted/50', filter: 'all', clickable: true, type: 'device' },
{ label: '链路', value: stats.linkCount, bg: 'bg-muted/50', clickable: false },
{ label: '正常', value: stats.online, bg: 'bg-emerald-500/10', color: 'text-emerald-400', filter: 'online', clickable: true, type: 'device' },
{ label: '警告', value: alarmCounts.warning, bg: 'bg-amber-500/10', color: 'text-amber-400', filter: 'warning', clickable: true, type: 'alert' },
{ label: '严重', value: alarmCounts.critical, bg: 'bg-red-500/10', color: 'text-red-400', filter: 'critical', clickable: true, type: 'alert' },
{ label: '离线', value: stats.offline, bg: 'bg-slate-500/10', color: 'text-slate-400', filter: 'offline', clickable: true, type: 'device' },
].map((item) => (
<div
key={item.label}
className={`p-2 rounded flex items-center justify-between ${item.bg} ${item.clickable ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
onClick={() => {
if (item.clickable && item.filter) {
if ((item as any).type === 'alert') {
openAlertList(item.label + '告警', item.filter);
} else {
openDeviceList(item.label + '设备', item.filter);
}
}
}}
>
<div className="text-muted-foreground">{item.label}</div>
<div className={`text-base font-semibold ${item.color ?? ''}`}>
{item.value}
</div>
</div>
))}
</div>
</div>
{/* 设备类型分布 */}
<div className="card rounded-lg border p-3 min-h-0 flex flex-col" style={{ background: '#1e293b', border: '1px solid rgba(148,163,184,0.15)' }}>
<div className="flex items-center gap-2 mb-1.5">
<Activity className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">设备类型分布</span>
</div>
<div ref={chartTypeRef} className="w-full flex-1 min-w-0 min-h-0 overflow-hidden" />
</div>
{/* CPU 负载 */}
<div className="card rounded-lg border p-3 min-h-0 flex flex-col" style={{ background: '#1e293b', border: '1px solid rgba(148,163,184,0.15)' }}>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">CPU 负载</span>
</div>
<span className="text-xs text-muted-foreground">{cpuList.length} 台</span>
</div>
<div className="flex-1 min-h-0 overflow-auto space-y-1.5 pr-1">
{cpuList.length === 0 && (
<div className="text-xs text-center py-2 text-muted-foreground">暂无巡检数据</div>
)}
{cpuList.map((dev) => (
<div
key={dev.id}
className="flex flex-col gap-0.5 p-1.5 rounded cursor-pointer hover:bg-cyan-500/10 transition-colors -mx-1.5"
onClick={() => openDeviceDetail(dev.id)}
>
<div className="flex items-center gap-2">
<span className="text-xs truncate flex-1" title={dev.name}>
{dev.name}
</span>
<div className="flex items-center gap-2 w-24">
<div className="flex-1 h-1.5 rounded-full overflow-hidden bg-muted">
<div
className="h-full rounded-full transition-all"
style={{
width: `${dev.cpuUsage}%`,
background:
dev.cpuUsage > 80
? '#ef4444'
: dev.cpuUsage > 60
? '#f59e0b'
: '#10b981',
}}
/>
</div>
<span
className="text-xs w-9 text-right font-mono"
style={{
color:
dev.cpuUsage > 80
? '#f87171'
: dev.cpuUsage > 60
? '#fbbf24'
: '#94a3b8',
}}
>
{dev.cpuUsage}%
</span>
</div>
</div>
<div className="text-[10px] text-muted-foreground pl-1">
{formatRelativeTime(dev.lastUpdate)}
</div>
</div>
))}
</div>
</div>
{/* 内存负载 */}
<div className="card rounded-lg border p-3 min-h-0 flex flex-col" style={{ background: '#1e293b', border: '1px solid rgba(148,163,184,0.15)' }}>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-400" />
<span className="text-sm font-medium">内存负载</span>
</div>
<span className="text-xs text-muted-foreground">{memoryList.length} 台</span>
</div>
<div className="flex-1 min-h-0 overflow-auto space-y-1.5 pr-1">
{memoryList.length === 0 && (
<div className="text-xs text-center py-2 text-muted-foreground">暂无巡检数据</div>
)}
{memoryList.map((dev) => (
<div
key={dev.id}
className="flex flex-col gap-0.5 p-1.5 rounded cursor-pointer hover:bg-cyan-500/10 transition-colors -mx-1.5"
onClick={() => openDeviceDetail(dev.id)}
>
<div className="flex items-center gap-2">
<span className="text-xs truncate flex-1" title={dev.name}>
{dev.name}
</span>
<div className="flex items-center gap-2 w-24">
<div className="flex-1 h-1.5 rounded-full overflow-hidden bg-muted">
<div
className="h-full rounded-full transition-all"
style={{
width: `${dev.memoryUsage}%`,
background:
dev.memoryUsage > 85
? '#ef4444'
: dev.memoryUsage > 70
? '#f59e0b'
: '#10b981',
}}
/>
</div>
<span
className="text-xs w-9 text-right font-mono"
style={{
color:
dev.memoryUsage > 85
? '#f87171'
: dev.memoryUsage > 70
? '#fbbf24'
: '#94a3b8',
}}
>
{dev.memoryUsage}%
</span>
</div>
</div>
<div className="text-[10px] text-muted-foreground pl-1">
{formatRelativeTime(dev.lastUpdate)}
</div>
</div>
))}
</div>
</div>
</aside>
{/* 中间 3D 主视区 */}
<section
className="relative rounded-lg overflow-hidden h-full min-h-0"
style={{
background:
'radial-gradient(ellipse at 50% 30%, rgba(14,165,233,0.08) 0%, transparent 70%)',
border: '1px solid rgba(148,163,184,0.15)',
}}
>
<div ref={containerRef} className="absolute inset-0 w-full h-full" />
{loading && (
<div
className="absolute inset-0 flex items-center justify-center z-10"
style={{ background: 'rgba(11,17,32,0.85)' }}
>
<div className="text-center">
<div
className="w-12 h-12 border-4 rounded-full mx-auto mb-3 animate-spin"
style={{
borderColor: 'rgba(14,165,233,0.3)',
borderTopColor: '#0ea5e9',
}}
/>
<div className="text-sm text-muted-foreground">加载 3D 拓扑全景中...</div>
</div>
</div>
)}
{/* 图例 */}
{legend && (
<div
className="absolute top-3 left-3 z-20 rounded-lg border p-3 pointer-events-auto"
style={{
background: 'rgba(15,23,42,0.88)',
borderColor: 'rgba(148,163,184,0.3)',
backdropFilter: 'blur(4px)',
}}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-foreground">图例</span>
<button onClick={() => setLegend(false)} className="text-xs text-muted-foreground hover:text-foreground">
&times;
</button>
</div>
<div className="space-y-1">
{[
{ color: 'bg-emerald-500', label: '正常' },
{ color: 'bg-amber-500', label: '警告' },
{ color: 'bg-red-500', label: '严重' },
{ color: 'bg-slate-500', label: '离线' },
].map((it) => (
<div key={it.label} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={`h-2 w-2 rounded-full ${it.color}`} /> {it.label}
</div>
))}
</div>
</div>
)}
{/* 底部控制栏 */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-2 z-20 pointer-events-auto">
<button
onClick={resetView}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
background: 'rgba(14,165,233,0.18)',
border: '1px solid rgba(34,211,238,0.35)',
color: '#67e8f9',
}}
>
<Maximize2 className="w-3.5 h-3.5" /> 重置视角
</button>
<button
onClick={refreshData}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
background: 'rgba(14,165,233,0.18)',
border: '1px solid rgba(34,211,238,0.35)',
color: '#67e8f9',
}}
>
<RefreshCw className={`w-3.5 h-3.5 ${autoRefreshInterval > 0 ? 'animate-spin' : ''}`} style={{ animationDuration: '3s' }} /> 刷新数据
</button>
<div className="relative">
<button
onClick={() => setShowRefreshMenu(!showRefreshMenu)}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{
background: autoRefreshInterval > 0 ? 'rgba(16,185,129,0.18)' : 'rgba(148,163,184,0.15)',
border: autoRefreshInterval > 0 ? '1px solid rgba(16,185,129,0.35)' : '1px solid rgba(148,163,184,0.25)',
color: autoRefreshInterval > 0 ? '#6ee7b7' : '#94a3b8',
}}
>
{autoRefreshInterval > 0
? autoRefreshInterval < 60
? `${autoRefreshInterval}秒`
: `${autoRefreshInterval / 60}分`
: '自动刷新'}
</button>
{showRefreshMenu && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setShowRefreshMenu(false)}
/>
<div
className="absolute right-0 bottom-full mb-1 z-40 rounded-lg border overflow-hidden shadow-xl"
style={{
background: '#1e293b',
borderColor: 'rgba(148,163,184,0.2)',
minWidth: '100px',
}}
>
{refreshOptions.map((opt) => (
<div
key={opt.value}
className="px-3 py-2 text-xs cursor-pointer transition-colors flex items-center justify-between"
style={{
color: autoRefreshInterval === opt.value ? '#67e8f9' : '#cbd5e1',
background: autoRefreshInterval === opt.value ? 'rgba(34,211,238,0.1)' : 'transparent',
}}
onClick={() => {
setAutoRefreshInterval(opt.value);
setShowRefreshMenu(false);
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(34,211,238,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = autoRefreshInterval === opt.value
? 'rgba(34,211,238,0.1)'
: 'transparent';
}}
>
{opt.label}
</div>
))}
</div>
</>
)}
</div>
</div>
{/* 右下角提示 */}
<div
className="absolute bottom-3 right-3 z-20 text-[11px] pointer-events-none px-2.5 py-1 rounded"
style={{
color: '#cbd5e1',
background: 'rgba(11,17,32,0.82)',
border: '1px solid rgba(148,163,184,0.2)',
}}
>
🖱️ 滚轮缩放 · 拖拽旋转 · 点击设备查看详情
</div>
</section>
{/* 右栏 */}
<aside
className="min-w-0 min-h-0 grid overflow-hidden w-full h-full gap-3"
style={{
gridTemplateRows: 'auto minmax(120px, 1fr) minmax(0,1fr) minmax(0,1fr)',
}}
>
{/* 巡检任务状态 */}
<div className="card rounded-lg border p-3 flex-shrink-0" style={{ background: '#1e293b', border: '1px solid rgba(148,163,184,0.15)' }}>
<div className="flex items-center gap-2 mb-1.5">
<ListTodo className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">巡检任务</span>
</div>
<div className="grid grid-cols-4 gap-1 text-center">
{[
{
icon: Loader2,
value: taskStats.running,
label: '执行中',
status: 'running',
color: '#38bdf8',
bg: 'rgba(14,165,233,0.15)',
},
{
icon: CheckCircle2,
value: taskStats.success,
label: '成功',
status: 'success',
color: '#34d399',
bg: 'rgba(16,185,129,0.15)',
},
{
icon: XCircle,
value: taskStats.failed,
label: '失败',
status: 'failed',
color: '#f87171',
bg: 'rgba(239,68,68,0.15)',
},
{
icon: Clock,
value: taskStats.pending,
label: '待执行',
status: 'pending',
color: '#94a3b8',
bg: 'rgba(148,163,184,0.15)',
},
].map((it) => (
<div
key={it.status}
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => openTaskList(it.label + '任务', it.status)}
>
<div
className="w-6 h-6 mx-auto rounded-full flex items-center justify-center mb-0.5"
style={{ background: it.bg }}
>
<it.icon className="w-3 h-3" style={{ color: it.color }} />
</div>
<div className="text-xs font-semibold leading-tight" style={{ color: it.color }}>
{it.value}
</div>
<div className="text-[9px] text-muted-foreground">{it.label}</div>
</div>
))}
</div>
</div>
{/* 告警趋势 */}
<div className="card rounded-lg border p-3 min-h-0 flex flex-col" style={{ background: '#1e293b', border: '1px solid rgba(148,163,184,0.15)' }}>
<div className="flex items-center gap-2 mb-1.5">
<TrendingUp className="w-4 h-4 text-primary" />
<span className="text-xs font-medium whitespace-nowrap">告警趋势 14 日</span>
</div>
<div ref={chartTrendRef} className="w-full flex-1 min-w-0 min-h-0 overflow-hidden" />
</div>
{/* 离线设备 */}
<div className="card rounded-lg border p-3 min-h-0 flex flex-col" style={{ background: '#1e293b', border: '1px solid rgba(148,163,184,0.15)' }}>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5 text-slate-400" />
<span className="text-xs font-medium">离线设备</span>
</div>
<span className="text-xs text-muted-foreground">{offlineDevices.length} 台</span>
</div>
<div className="flex-1 min-h-0 overflow-auto space-y-1 pr-1">
{offlineDevices.length === 0 && (
<div className="text-[11px] text-center py-2 text-emerald-400">
全部设备在线
</div>
)}
{offlineDevices.map((d) => (
<div
key={d.id}
className="flex items-center gap-2 text-[11px] p-1.5 rounded cursor-pointer hover:bg-slate-700/50 transition-colors"
style={{ background: 'rgba(148,163,184,0.06)' }}
onClick={() => openDeviceDetail(d.id)}
>
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: '#6b7280' }}
/>
<div className="flex-1 min-w-0">
<div className="truncate text-slate-300 font-medium">{d.name}</div>
<div className="truncate text-slate-500 text-[10px]">
{d.ip || '-'} · {formatRelativeTime(d.lastUpdate)}
</div>
</div>
<span className="text-slate-500 text-[10px] flex-shrink-0">离线</span>
</div>
))}
</div>
</div>
{/* 实时告警 */}
<div className="card rounded-lg border p-3 min-h-0 flex flex-col" style={{ background: '#1e293b', border: '1px solid rgba(148,163,184,0.15)' }}>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2 cursor-pointer" onClick={() => openAlertList('实时告警', 'all')}>
{activeAlarms.length > 0 ? (
<AlertTriangle className="w-4 h-4 text-red-400" />
) : (
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
)}
<span className="text-sm font-medium">实时告警</span>
</div>
{activeAlarms.length > 0 ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/15 text-red-400">
{activeAlarms.length}
</span>
) : (
<span className="text-[10px] text-emerald-400">系统正常</span>
)}
</div>
{activeAlarms.length > 0 ? (
<div className="flex-1 min-h-0 overflow-auto space-y-1 pr-1">
{activeAlarms.map((alarm) => (
<div
key={alarm.id}
className="p-1.5 rounded text-[11px] border-l-2"
style={{
background: 'rgba(239,68,68,0.08)',
borderLeftColor:
alarm.level === 'critical'
? '#ef4444'
: alarm.level === 'warning'
? '#f59e0b'
: '#60a5fa',
}}
>
<span
className="font-medium truncate block"
style={{
color:
alarm.level === 'critical'
? '#f87171'
: alarm.level === 'warning'
? '#fbbf24'
: '#60a5fa',
}}
>
[{alarm.level}] {alarm.message}
</span>
<div className="mt-0.5 flex items-center gap-1 text-[10px]" style={{ color: '#94a3b8' }}>
<span className="truncate flex-1">{alarm.deviceName}</span>
<span className="flex-shrink-0 font-mono">{alarm.time}</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-3">
<div
className="w-8 h-8 mx-auto rounded-full flex items-center justify-center mb-1"
style={{
background: 'rgba(16,185,129,0.15)',
border: '1px solid rgba(16,185,129,0.25)',
}}
>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<div className="text-[11px] text-emerald-400">当前无活动告警</div>
<div className="text-[10px] mt-0.5 text-muted-foreground">设备运行一切正常</div>
</div>
)}
</div>
</aside>
{/* 设备详情弹窗 */}
{/* 连线类型选择对话框 */}
{pendingLink && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.6)' }}
onClick={() => setPendingLink(null)}
>
<div
className="rounded-lg shadow-2xl p-4 w-[280px]"
style={{
background: 'rgba(15,23,42,0.98)',
border: '1px solid rgba(148,163,184,0.25)',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="text-sm font-medium mb-3 text-slate-200">选择链路类型</div>
<div className="grid grid-cols-2 gap-2">
{[
{ type: 'wan', label: 'WAN', color: '#06b6d4' },
{ type: 'l3', label: 'L3路由', color: '#8b5cf6' },
{ type: 'l2', label: 'L2交换', color: '#3b82f6' },
{ type: 'access', label: '接入', color: '#22c55e' },
{ type: 'storage', label: '存储', color: '#f59e0b' },
{ type: 'vpn', label: 'VPN', color: '#ec4899' },
{ type: 'wireless', label: '无线', color: '#14b8a6' },
{ type: 'custom', label: '自定义', color: '#6b7280' },
].map(({ type, label, color }) => (
<button
key={type}
className="px-3 py-2 rounded text-xs text-left hover:opacity-80 transition-opacity"
style={{
background: `${color}20`,
border: `1px solid ${color}50`,
color: color,
}}
onClick={() => {
// 创建链路
const newLink: Link = {
id: `custom-${Date.now()}`,
source: pendingLink.sourceId,
target: pendingLink.targetId,
type,
};
setCustomLinks((prev) => [...prev, newLink]);
console.log('[连线] 创建链路:', pendingLink.sourceId, '->', pendingLink.targetId, type);
setPendingLink(null);
}}
>
{label}
</button>
))}
</div>
<button
className="w-full mt-3 px-3 py-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
onClick={() => setPendingLink(null)}
>
取消
</button>
</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
className="fixed z-[9999] rounded-lg shadow-xl py-1 min-w-[160px]"
style={{
left: contextMenu.x,
top: contextMenu.y,
background: 'rgba(15,23,42,0.98)',
border: '1px solid rgba(148,163,184,0.25)',
}}
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full px-3 py-1.5 text-left text-xs hover:bg-slate-700/60 text-slate-300 flex items-center gap-2"
onClick={() => {
if (linkModeUI) {
// 连线模式:取消
setLinkMode(null);
} else {
// 普通模式:开始连线
setLinkMode({ sourceId: contextMenu.deviceId });
}
setContextMenu(null);
}}
>
<svg className="w-3.5 h-3.5 text-cyan-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
{linkModeUI ? '取消连线' : '从此设备开始连线'}
</button>
<button
className="w-full px-3 py-1.5 text-left text-xs hover:bg-slate-700/60 text-slate-300 flex items-center gap-2"
onClick={() => {
// 重置位置(暂不实现)
setContextMenu(null);
}}
>
<svg className="w-3.5 h-3.5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
重置位置
</button>
<div className="my-1 border-t border-slate-700/50" />
<button
className="w-full px-3 py-1.5 text-left text-xs hover:bg-slate-700/60 text-slate-300 flex items-center gap-2"
onClick={() => {
const dev = deviceMapRef.current.get(contextMenu.deviceId);
if (dev) {
// TODO: 打开设备详情
}
setContextMenu(null);
}}
>
<svg className="w-3.5 h-3.5 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
查看详情
</button>
</div>
)}
{/* 详情面板 */}
<MonitoringDetailPanel
open={detailPanel.open}
onClose={closeDetailPanel}
panelType={detailPanel.type}
panelTitle={detailPanel.title}
deviceList={panelDeviceList}
alertList={panelAlertList}
deviceDetail={panelDeviceDetail}
taskList={panelTaskList}
onDeviceClick={(deviceId) => openDeviceDetail(deviceId)}
/>
</div>
</>
);
}
export default MonitoringTopologyView;