新增设备巡检模块核心功能,包括: - 巡检数据存储与调度系统 - 华为/H3C交换机及Linux服务器采集器 - 设备扩展配置与告警规则管理 - 主机详情页巡检配置入口 - 产品名称自定义设置功能 - 网络设备SSH exec通道兼容处理
2410 lines
90 KiB
TypeScript
2410 lines
90 KiB
TypeScript
/**
|
||
* 运行监控 - 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">
|
||
×
|
||
</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;
|