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;
|