Files
SenMeshNet/components/monitoring/MonitoringTopologyView.tsx

3809 lines
162 KiB
TypeScript
Raw Normal View History

/**
* 运行监控 - 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 { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
import { Line2 } from 'three/examples/jsm/lines/Line2.js';
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js';
import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js';
import * as echarts from 'echarts';
import { STORAGE_KEY_MONITORING_DASHBOARD_TITLE } from '../../infrastructure/config/storageKeys';
import {
Network,
Clock,
Maximize2,
Minimize2,
RefreshCw,
Server,
Activity,
TrendingUp,
AlertTriangle,
CheckCircle2,
ListTodo,
XCircle,
Loader2,
Bell,
Expand,
Box,
Link2,
CircleDot,
AlertOctagon,
WifiOff,
PieChart,
ArrowUpRight,
Cpu,
HardDrive,
BellRing,
Volume2,
VolumeX,
Info,
Monitor,
Router,
Shield,
Database,
} 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[];
}
// ===== 全局单例:WebGLRenderer / CSS2DRenderer =====
// 浏览器 WebGL context 上限 8-16,短时间 mount/unmount 多次会丢 oldest。
// 这里挂到 window 上做真正的全局单例 —— HMR 模块替换不会重置 window 变量。
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _W = window as any;
function disposeMatTextures(mat: any) {
if (!mat) return;
// 1. 遍历材质直接属性(map / normalMap / emissiveMap 等)
for (const key of Object.keys(mat)) {
const v = mat[key];
if (v && v.isTexture) v.dispose?.();
}
// 2. ShaderMaterial 的贴图在 uniforms 里(ditu.png 在 uniforms.uMap.value)
if (mat.uniforms) {
for (const uKey of Object.keys(mat.uniforms)) {
const u = mat.uniforms[uKey];
if (u?.value?.isTexture) u.value.dispose?.();
}
}
mat.dispose?.();
}
function getGlobalWebGLRenderer(): THREE.WebGLRenderer {
if (!_W.__netMeshWebGLRenderer) {
_W.__netMeshWebGLRenderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance',
});
const r: THREE.WebGLRenderer = _W.__netMeshWebGLRenderer;
r.setPixelRatio(Math.min(window.devicePixelRatio, 2));
r.setClearColor(0x000000, 0);
r.localClippingEnabled = true;
r.domElement.style.cursor = 'grab';
r.domElement.style.pointerEvents = 'auto';
r.domElement.style.display = 'block';
r.domElement.style.width = '100%';
r.domElement.style.height = '100%';
}
return _W.__netMeshWebGLRenderer as THREE.WebGLRenderer;
}
function getGlobalCSS2DRenderer(): CSS2DRenderer {
if (!_W.__netMeshCSS2DRenderer) {
_W.__netMeshCSS2DRenderer = new CSS2DRenderer();
const c: CSS2DRenderer = _W.__netMeshCSS2DRenderer;
c.domElement.style.position = 'absolute';
c.domElement.style.top = '0';
c.domElement.style.left = '0';
c.domElement.style.width = '100%';
c.domElement.style.height = '100%';
c.domElement.style.pointerEvents = 'none';
c.domElement.style.zIndex = '10';
}
return _W.__netMeshCSS2DRenderer as CSS2DRenderer;
}
// 统一的卡片标题条 —— 发光图标块 + 标题 + 右侧 >>> 装饰箭头
const CardHeader: React.FC<{
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
title: string;
iconColor?: string;
iconBg?: string;
extra?: React.ReactNode;
}> = ({ icon: Icon, title, iconColor = '#67e8f9', iconBg = 'rgba(14,165,233,0.25)', extra }) => (
<div
className="flex items-center gap-2 mb-2 px-2 py-1.5 rounded-sm"
style={{
background: 'linear-gradient(90deg, rgba(58,123,213,0.35) 0%, rgba(58,123,213,0.15) 60%, transparent 100%)',
borderLeft: `2px solid ${iconColor}`,
boxShadow: 'inset 0 0 12px rgba(58,123,213,0.15)',
}}
>
<div
className="w-5 h-5 rounded-sm flex items-center justify-center flex-shrink-0"
style={{
background: iconBg,
border: `1px solid ${iconColor}88`,
boxShadow: `0 0 8px ${iconColor}55`,
}}
>
<Icon className="w-3 h-3" style={{ color: iconColor }} />
</div>
<span className="text-[13px] font-semibold text-white flex-1 tracking-wide">{title}</span>
{extra && <div className="flex-shrink-0">{extra}</div>}
<div className="flex items-center gap-0 flex-shrink-0" style={{ color: iconColor }}>
<span style={{ opacity: 0.35, fontSize: 10 }}>›</span>
<span style={{ opacity: 0.6, fontSize: 11 }}>›</span>
<span style={{ opacity: 0.9, fontSize: 12 }}>›</span>
</div>
</div>
);
// 设备类型 → 图标组件
const deviceTypeIconMap: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
server: Server,
switch: Network,
router: Router,
firewall: Shield,
storage: Database,
workstation: Monitor,
h3c: Network,
huawei: Network,
};
const getDeviceIcon = (type?: string) => deviceTypeIconMap[type || ''] || Server;
// 设备类型映射
const typeLabel: Record<string, string> = {
server: '服务器',
switch: '交换机',
router: '路由器',
firewall: '防火墙',
storage: '存储',
workstation: '工作站',
h3c: '交换机',
huawei: '交换机',
};
// 状态颜色
const statusColors: Record<string, number> = {
normal: 0x86efac, // 浅绿色 tailwind green-300
warning: 0xffe600,
critical: 0xff4d4f,
offline: 0xcbd5e1,
};
const statusHex: Record<string, string> = {
normal: '#86EFAC',
warning: '#FFE600',
critical: '#FF4D4F',
offline: '#6b7280',
};
/** 标签样式唯一入口:根据设备状态返回渐变背景 + 边框色,兜底灰色 */
function getLabelStyle(status: string): { background: string; border: string; glow: string; text: string } {
// normal:浅米色 #E4E4E1 — 干净清爽,区别于设备模型荧光绿
if (status === 'normal') {
return {
background: '#5fc06fff',
border: '1px solid #B8B4AE',
glow: '#E4E4E133',
text: '#ffffffff',
};
}
// offline:浅灰底 + 红字,清晰标识又不致黑
if (status === 'offline') {
return {
background: 'linear-gradient(135deg, #cbd5e1ee, #b8c3d3dd)',
border: '1px solid #94a3b8cc',
glow: '#ff4d4f33',
text: '#ff4d4f',
};
}
// warning / critical 保持荧光色渐变 + 白字
const hex = statusHex[status] ?? statusHex.offline;
return {
background: `linear-gradient(135deg, ${hex}ee, ${hex}88)`,
border: `1px solid ${hex}cc`,
glow: `${hex}55`,
text: '#ffffff',
};
}
// 链路颜色(不同链路类型不同色系,在浅卫星地图上清晰可辨)
const edgeColorMap: Record<string, number> = {
wan: 0x2196f3, // 蓝 — WAN 骨干
uplink: 0x2196f3, // 蓝 — 上行
l3: 0x4caf50, // 深绿 — L3 路由
l2: 0x00bcd4, // 深青 — L2 交换
storage: 0xffa500, // 橙 — 存储
access: 0x9c27b0, // 深紫 — 接入点
vpn: 0x2196f3, // 蓝 — VPN
wireless: 0x66bb6a, // 浅绿 — 无线
default: 0x2196f3,
};
// 设备安全边界常量
const MIN_Y = -0.5;
const MAX_Y = 20;
// 创建离线连线的"断开"叉叉标记(两条交叉细线 + 红色发光材质)
function createBreakMark(): THREE.Group {
const group = new THREE.Group();
const size = 0.45;
const mat = new THREE.LineBasicMaterial({
color: 0xff4d4f,
depthTest: false,
transparent: true,
opacity: 1.0,
});
// 两条对角线
const pts1 = [new THREE.Vector3(-size, size, 0), new THREE.Vector3(size, -size, 0)];
const pts2 = [new THREE.Vector3(-size, -size, 0), new THREE.Vector3(size, size, 0)];
const geo1 = new THREE.BufferGeometry().setFromPoints(pts1);
const geo2 = new THREE.BufferGeometry().setFromPoints(pts2);
const line1 = new THREE.Line(geo1, mat);
const line2 = new THREE.Line(geo2, mat);
group.add(line1);
group.add(line2);
// 外圈小圆圈
const ringSize = size * 1.8;
const ringGeo = new THREE.RingGeometry(ringSize - 0.12, ringSize, 32);
// 用 LineLoop 更像"空心"圈 —— 先转成 BufferGeometry 上的点
const ringPts: THREE.Vector3[] = [];
const segs = 36;
for (let i = 0; i <= segs; i++) {
const a = (i / segs) * Math.PI * 2;
ringPts.push(new THREE.Vector3(Math.cos(a) * ringSize, Math.sin(a) * ringSize, 0));
}
const ringLine = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(ringPts),
mat
);
group.add(ringLine);
group.renderOrder = 10;
return group;
}
// 对每条边生成稳定 hash 的弧线中间控制点,让汇聚连线沿不同方向散开
function computeArcMidpoint(a: THREE.Vector3, b: THREE.Vector3, hashKey: string): { mid: THREE.Vector3 } {
// 简单 hash → 0..1
let h = 0;
for (let i = 0; i < hashKey.length; i++) h = (h * 31 + hashKey.charCodeAt(i)) >>> 0;
const t = (h & 0xffff) / 0xffff; // 0..1
const angle = t * Math.PI * 2; // 0..2π 随机方向
// 连线中点
const mx = (a.x + b.x) / 2;
const mz = (a.z + b.z) / 2;
const my = (a.y + b.y) / 2;
// 偏移:沿 xz 平面随机方向,幅值按连线长度
const dx = b.x - a.x;
const dz = b.z - a.z;
const len = Math.sqrt(dx * dx + dz * dz) || 1;
// 偏移量 = min(连线长度 * 0.25, 7),避免过弯
const amp = Math.min(len * 0.25, 7);
const ox = Math.cos(angle) * amp;
const oz = Math.sin(angle) * amp;
// 中间点也稍抬高一点,让弧线"拱"起来,不和地图贴平
const oy = my + 1.5;
return { mid: new THREE.Vector3(mx + ox, oy, mz + oz) };
}
// 相对时间格式化
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 {
// 优先用直接的 type 字段(remote API 会返回 switch/server/firewall/router)
const directType = (host as any).type;
if (directType && ['server', 'switch', 'router', 'firewall', 'storage', 'workstation'].includes(directType)) {
return directType;
}
const distro = (host.distro || (host as any).catalog || (host as any).catalog_id || '').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');
}
});
// 默认不生成连线,只使用用户手动添加的 customLinks
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] ?? 0x22c55e;
if (status === 'critical' || status === 'warning') {
const halo = new THREE.PointLight(color, status === 'critical' ? 2.5 : 1.5, 14);
halo.position.set(0, 0, 0);
group.add(halo);
}
// 所有设备加状态光环(增强大屏可见性)
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.35, 0.55, 24),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: status === 'offline' ? 0.15 : 0.35,
side: THREE.DoubleSide,
})
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -0.02;
ring.renderOrder = 3;
group.add(ring);
// 双面材质兜底
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 [isTopologyOnly, setIsTopologyOnly] = useState(false); // 3D拓扑大屏模式
const [isPanelsHidden, setIsPanelsHidden] = useState(false); // 面板正在滑出/已滑出(过渡动画用)
const [currentTime, setCurrentTime] = useState(new Date());
const [legend, setLegend] = useState(true);
const [showConnectionLegend, setShowConnectionLegend] = useState(true);
const [monitoringTitle, setMonitoringTitle] = useState<string>(() => {
// 先给默认值,useEffect 里远程异步加载(时序更安全)
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
if (saved) return saved;
}
return '云启Net智能巡检监测系统';
});
// 远程环境:异步加载标题 / 位置 / 连接线(useState init 阶段 token 可能还没注入)
useEffect(() => {
if (typeof window === "undefined") return;
const token = (window as any).__REMOTE_TOKEN__;
if (!token) return; // 不是远程环境或 token 未就绪
const fetchJSON = (url: string): Promise<any> =>
fetch(url).then(r => r.json()).catch(() => null);
// 1. 标题
fetchJSON(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
.then(json => { if (json?.ok && json.title) setMonitoringTitle(json.title); });
// 2. 拓扑位置
fetchJSON(`/api/topology-positions?token=${encodeURIComponent(token)}`)
.then(json => {
if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
setSavedPositions(json.positions);
}
});
// 3. 连接线
fetchJSON(`/api/topology-links?token=${encodeURIComponent(token)}`)
.then(json => { if (json?.ok && Array.isArray(json.links)) setCustomLinks(json.links); });
}, []);
// Electron 环境:监听 localStorage 变化 + mount 时同步 hosts/title 给主进程
useEffect(() => {
const nc = (window as any).netcatty;
console.log('[MonitoringTopologyView sync] nc exists:', !!nc, 'nc.inspection exists:', !!nc?.inspection, 'hosts.length:', hosts?.length);
if (!nc?.inspection) {
console.log('[MonitoringTopologyView sync] skip — not Electron env');
return;
}
// mount 时一次性同步 vault hosts + 标题 + 拓扑位置 + 拓扑连线
if (hosts?.length > 0) {
console.log('[MonitoringTopologyView sync] saving vault hosts:', hosts.length);
nc.inspection.saveVaultHosts(hosts).then((r: any) => console.log('saveVaultHosts result:', r)).catch((e: any) => console.warn('saveVaultHosts error:', e));
}
const savedTitle = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
console.log('[MonitoringTopologyView sync] savedTitle:', savedTitle);
if (savedTitle) nc.inspection.saveDashboardTitle(savedTitle).catch(() => {});
const savedPos = localStorage.getItem('monitoring-topology-positions');
if (savedPos) nc.inspection.saveTopologyPositions(JSON.parse(savedPos)).catch(() => {});
const savedLinks = localStorage.getItem('monitoring-topology-links');
console.log('[MonitoringTopologyView sync] savedLinks exists:', !!savedLinks);
if (savedLinks) {
try { nc.inspection.saveTopologyLinks(JSON.parse(savedLinks)).catch(() => {}); } catch {}
}
if (!savedLinks && nc.inspection.getTopologyLinks) {
nc.inspection.getTopologyLinks().then((linksFromMain: any) => {
if (Array.isArray(linksFromMain) && linksFromMain.length > 0) {
console.info('[MonitoringTopologyView] restore links from main:', linksFromMain.length);
localStorage.setItem('monitoring-topology-links', JSON.stringify(linksFromMain));
try { setCustomLinks(linksFromMain); } catch {}
}
}).catch(() => {});
}
const syncToMain = (value: string) => {
if (value) nc.inspection.saveDashboardTitle(value).catch(() => {});
};
const handler = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_MONITORING_DASHBOARD_TITLE && e.newValue !== null) {
setMonitoringTitle(e.newValue || '云启Net智能巡检监测系统');
syncToMain(e.newValue);
}
};
const localHandler = () => {
const v = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
setMonitoringTitle(v || '云启Net智能巡检监测系统');
syncToMain(v || '');
};
window.addEventListener('storage', handler);
window.addEventListener('monitoringTitleChanged' as any, localHandler);
// mount 时同步一次当前标题
const initVal = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
if (initVal) syncToMain(initVal);
return () => {
window.removeEventListener('storage', handler);
window.removeEventListener('monitoringTitleChanged' as any, localHandler);
};
}, []);
// Three.js 全局引用
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const composerRef = useRef<EffectComposer | null>(null);
const labelRendererRef = useRef<CSS2DRenderer | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const nodesRef = useRef<THREE.Group[]>([]);
const edgesRef = useRef<Line2[]>([]);
const linkBreakRef = useRef<THREE.Group[]>([]); // 离线连线的"断开"标记叉叉
const labelsRef = useRef<HTMLDivElement[]>([]);
const deviceMapRef = useRef<Map<string, any>>(new Map());
// deviceId → THREE.Group 的快速查找表(替代 animate 里的 Array.find,O(1))
const nodeGroupMapRef = useRef<Map<string, THREE.Group>>(new Map());
const raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
const pointerRef = useRef<THREE.Vector2>(new THREE.Vector2());
const animIdRef = useRef<number>(0);
// 每个设备的持久 baseY(不受 animate 浮动或拖拽影响的真值)
const deviceBaseYRef = useRef<Map<string, number>>(new Map());
const positionsRef = useRef<Record<string, THREE.Vector3>>({});
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() });
// 相机参数 —— 正常模式(45°俯视)和大屏模式(正俯视)
const normalCameraRef = useRef({ pos: new THREE.Vector3(0, 55, 115), target: new THREE.Vector3(-20, -25, 20), minDist: 30, maxDist: 150, autoRotate: true, autoRotateSpeed: 0.4, fov: 60 });
// 相机平滑过渡动画(Vector3.lerp 逐帧插值)
interface CameraAnim {
fromPos: THREE.Vector3;
toPos: THREE.Vector3;
fromTarget: THREE.Vector3;
toTarget: THREE.Vector3;
fromFov: number;
toFov: number;
startMs: number;
durationMs: number;
onComplete?: () => void;
onCompleteRestoreControls?: boolean;
}
const cameraAnimRef = useRef<CameraAnim | null>(null);
// easeInOutCubic:前半段慢加速,中间快,后半段慢减速(线性的太生硬)
const easeInOutCubic = (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// 告警提示音:静音状态(localStorage 持久化)
const [isMuted, setIsMuted] = useState<boolean>(() => {
try { return localStorage.getItem('monitoring-alert-muted') === '1'; } catch { return false; }
});
// 去重:避免同级别告警短时间连续响
const _lastAlertSoundRef = useRef<{ time: number; level: string }>({ time: 0, level: '' });
// 预渲染好的 AI 告警音 AudioBuffer(首次渲染时生成,之后直接复用)
const _buffersRef = useRef<AudioBuffer | null>(null);
// 常驻 AudioContext(resume 一次即可,避免每次 new 都可能是 suspended 状态)
const _persistentCtxRef = useRef<AudioContext | null>(null);
// ===== 每次用户交互都尝试 resume AudioContext =====
// Chromium 无用户交互 8 秒后会 suspend AudioContext,所以不能只监听第一次
useEffect(() => {
const AC = (window.AudioContext || (window as any).webkitAudioContext);
if (!AC) return;
const ensureActive = () => {
try {
if (!_persistentCtxRef.current) {
_persistentCtxRef.current = new AC() as AudioContext;
}
const ctx = _persistentCtxRef.current;
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
} catch {}
};
window.addEventListener('pointerdown', ensureActive);
window.addEventListener('keydown', ensureActive);
window.addEventListener('touchstart', ensureActive);
return () => {
window.removeEventListener('pointerdown', ensureActive);
window.removeEventListener('keydown', ensureActive);
window.removeEventListener('touchstart', ensureActive);
if (_persistentCtxRef.current) {
try { _persistentCtxRef.current.close(); } catch {}
}
};
}, []);
// ===== OfflineAudioContext 预渲染引擎 =====
// 使用 AI 合成脚本:循环 beep (880Hz square + 1200Hz sine) + lowpass 白噪背景,共 3s
useEffect(() => {
let cancelled = false;
const AC = (window.AudioContext || (window as any).webkitAudioContext);
if (!AC) return;
(async () => {
try {
const buf = await renderAlarmBeep();
if (!cancelled) _buffersRef.current = buf;
} catch { /* 静默 */ }
})();
return () => { cancelled = true; };
}, []);
// —— 用户提供的 AI 告警合成脚本(移植为 TypeScript + Promise)——
function renderAlarmBeep(): Promise<AudioBuffer> {
const SR = 44100;
const dur = 3.0;
// 必须用 OfflineAudioContext 构造(不是 AudioContext),只有它有 startRendering 方法
const OAC = (window as any).OfflineAudioContext || (window as any).webkitOfflineAudioContext;
if (!OAC) throw new Error('OfflineAudioContext not supported');
const octx = new OAC(1, Math.floor(SR * dur), SR);
const dest = octx.destination;
// Helper: noise buffer
const noise = (ctx: any, d: number) => {
const size = Math.max(1, Math.floor(ctx.sampleRate * d));
const buf = ctx.createBuffer(1, size, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < size; i++) data[i] = Math.random() * 2 - 1;
return buf;
};
// Helper: play tone with linear envelope
const tone = (freq: number, type: OscillatorType, d: number, vol: number, start: number) => {
const osc = octx.createOscillator();
const g = octx.createGain();
osc.type = type; osc.frequency.value = freq;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol, start + 0.01);
g.gain.setValueAtTime(vol, start + d * 0.6);
g.gain.linearRampToValueAtTime(0, start + d);
osc.connect(g); g.connect(dest);
osc.start(start); osc.stop(start + d + 0.01);
};
// Gentle slow repeating alert, not harsh or fast
// Longer on/off cycle: 0.45s beep, 0.35s pause, warm sine/triangle waveforms (no sharp square)
tone(620, 'triangle', 0.45, 0.18, 0.0);
tone(930, 'sine', 0.45, 0.09, 0.0);
tone(620, 'triangle', 0.45, 0.18, 0.8);
tone(930, 'sine', 0.45, 0.09, 0.8);
tone(620, 'triangle', 0.45, 0.18, 1.6);
tone(930, 'sine', 0.45, 0.09, 1.6);
tone(620, 'triangle', 0.45, 0.18, 2.4);
tone(930, 'sine', 0.45, 0.09, 2.4);
// Soft low filtered ambient tech hum background, low volume
const noiseBuf = noise(octx, dur);
const noiseSrc = octx.createBufferSource();
const noiseGain = octx.createGain();
const filter = octx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 1600;
noiseGain.gain.setValueAtTime(0, 0);
noiseGain.gain.linearRampToValueAtTime(0.04, 0.05);
noiseGain.gain.linearRampToValueAtTime(0, dur);
noiseSrc.buffer = noiseBuf;
noiseSrc.connect(filter);
filter.connect(noiseGain);
noiseGain.connect(dest);
noiseSrc.start(0); noiseSrc.stop(dur);
// startRendering 兼容:Chromium 旧版是 callback,新版返回 Promise
return new Promise<AudioBuffer>((resolve, reject) => {
const p = octx.startRendering();
if (p && typeof p.then === 'function') {
// 新版 Promise 风格
p.then(resolve, reject);
} else {
// 旧版 callback 风格
try { octx.oncomplete = (e: any) => resolve(e.renderedBuffer); } catch {}
try { octx.startRendering(() => {}); } catch (e) { reject(e); }
}
});
}
// 播放一次(响完自动停)。每次 ensureActive 已经负责 resume,这里只负责 start
const playAlertSoundOnce = useCallback(() => {
if (isMuted) return;
try {
const AC = (window.AudioContext || (window as any).webkitAudioContext);
if (!AC) return;
const buf = _buffersRef.current;
if (!buf) return;
let ctx = _persistentCtxRef.current;
if (!ctx) ctx = new AC() as AudioContext;
// 兜底:suspended 就再 resume 一次(ensureActive 本来已经在 resume 了)
if (ctx.state === 'suspended') {
ctx.resume().catch(() => {});
}
const src = ctx.createBufferSource();
src.buffer = buf;
const g = ctx.createGain();
g.gain.value = 0.9;
src.connect(g); g.connect(ctx.destination);
src.start();
setTimeout(() => {
try { src.stop(); } catch {}
try { src.disconnect(); g.disconnect(); } catch {}
}, 4000);
} catch { /* 静默 */ }
}, [isMuted]);
// 每次 pointerdown 都会触发 ensureActive → ctx.resume(),取消静音那次点击就是一次有效 resume
// 取消静音后主动响一次(等 ctx 真 active 再播),让用户立刻确认恢复
const prevMutedRef = useRef(isMuted);
useEffect(() => {
if (prevMutedRef.current && !isMuted) {
// muted → unmuted 转换:等一帧让 ensureActive 的 resume 完成再播
setTimeout(() => playAlertSoundOnce(), 300);
}
prevMutedRef.current = isMuted;
}, [isMuted, playAlertSoundOnce]);
// 每 30 秒自动响一次(未静音时)—— 用 ref 替代 state 依赖避免定时器重建开销
const isMutedRef = useRef(isMuted);
useEffect(() => { isMutedRef.current = isMuted; }, [isMuted]);
useEffect(() => {
const id = setInterval(() => {
if (!isMutedRef.current) playAlertSoundOnce();
}, 30_000);
return () => clearInterval(id);
}, [playAlertSoundOnce]);
// 监听告警变化 → 播放提示音(只对新产生的告警)
useEffect(() => {
const api = (window as any).netcatty?.inspection;
if (!api?.onAlertChanged) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const handler = async () => {
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
try {
const alerts = await api.listAlerts({ limit: 1 });
const latest = Array.isArray(alerts) && alerts.length > 0 ? alerts[0] : null;
if (!latest) return;
// 新告警才响:用 triggered_at 和 id 作为去重
const key = `${latest.id}_${latest.triggered_at}`;
if ((handler as any)._lastKey === key) return;
(handler as any)._lastKey = key;
playAlertSoundOnce();
} catch { /* 忽略 */ }
}, 800);
};
const unsub = api.onAlertChanged(handler);
return () => {
if (timer) clearTimeout(timer);
try { unsub?.(); } catch (_) {}
};
}, [playAlertSoundOnce]);
// 正俯视: pos = target + (0, height, 0),polar angle ≈ 0
const TOPOLOGY_TARGET = new THREE.Vector3(-20, -40, 20);
const TOPOLOGY_HEIGHT = 180;
// 大屏模式相机:更西南侧、拉远距离确保完整地图 + 所有设备都在视野内
const topologyOnlyCamera = {
pos: new THREE.Vector3(TOPOLOGY_TARGET.x - 140, TOPOLOGY_TARGET.y + 160, TOPOLOGY_TARGET.z + 90),
target: TOPOLOGY_TARGET.clone(),
minDist: 80,
maxDist: 600,
autoRotate: true,
autoRotateSpeed: 0.3,
minPolarAngle: Math.PI / 5,
maxPolarAngle: Math.PI / 2 - 0.01,
};
// 保存原始天空/背景用于大屏模式切换恢复
const skyBackupRef = useRef<{ background: any; fog: any } | null>(null);
// 切换 3D 拓扑大屏模式(面板滑出/滑入,**相机完全不动**,只开/关 autoRotate)
const toggleTopologyOnly = useCallback(() => {
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!camera || !controls) { setIsTopologyOnly((v) => !v); return; }
if (!isTopologyOnly) {
// === 进入大屏 ===
// 保存当前相机参数(用户可能已手动调整过视角)
normalCameraRef.current.pos.copy(camera.position);
normalCameraRef.current.target.copy(controls.target);
normalCameraRef.current.autoRotate = controls.autoRotate;
normalCameraRef.current.fov = camera.fov;
// 面板滑出 + 开启自动旋转(相机位置不变)
setIsPanelsHidden(true);
controls.autoRotate = true;
controls.autoRotateSpeed = 0.3;
controls.enablePan = false; // 禁平移,防止用户转着转出视野
// 等面板滑出后切 grid + 卸载 aside
setTimeout(() => {
setIsTopologyOnly(true);
}, 360);
} else {
// === 退出大屏 ===
controls.enablePan = true;
// autoRotate 保持不变(任何模式都自动旋转)
setIsTopologyOnly(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => setIsPanelsHidden(false));
});
}
}, [isTopologyOnly]);
// 退出大屏时触发 ECharts resize(aside 从条件隐藏中 remount 需要重新撑满)
useEffect(() => {
if (isTopologyOnly) return;
const resizeAll = () => chartsRef.current.forEach((c) => {
try { c.resize(); } catch { /* ignore */ }
});
requestAnimationFrame(resizeAll);
setTimeout(resizeAll, 100);
setTimeout(resizeAll, 300);
setTimeout(resizeAll, 600);
}, [isTopologyOnly]);
// 时钟
useEffect(() => {
const id = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
// 右键菜单状态
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; deviceId: string } | null>(null);
const [deleteLinkConfirm, setDeleteLinkConfirm] = useState<{
x: number; y: number; sourceId: string; targetId: string; isCustom: boolean;
} | null>(null);
// 已删除的 auto 生成连线(避免刷新后又出现)
const [excludedAutoLinks, setExcludedAutoLinks] = useState<Link[]>(() => {
try {
const saved = localStorage.getItem('monitoring-topology-excluded-links');
return saved ? JSON.parse(saved) : [];
} catch { return []; }
});
const [pendingLink, setPendingLink] = useState<{ sourceId: string; targetId: string } | null>(null);
// 用户创建的连线 —— 默认空数组,远程/本地各自从 useEffect 加载
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]);
// 连线变化时同步到主进程 inspection-data.json(供远程浏览器 API 读取)
useEffect(() => {
const nc = (window as any).netcatty;
if (!nc?.inspection) return;
nc.inspection.saveTopologyLinks(customLinks).catch(() => {});
}, [customLinks]);
useEffect(() => {
localStorage.setItem('monitoring-topology-excluded-links', JSON.stringify(excludedAutoLinks));
}, [excludedAutoLinks]);
useEffect(() => {
try { localStorage.setItem('monitoring-alert-muted', isMuted ? '1' : '0'); } catch {}
}, [isMuted]);
const toggleMute = useCallback(() => setIsMuted((v) => !v), []);
// 全屏:运行监控独占大屏(覆盖左侧菜单/顶部,ESC 退出)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
e.preventDefault();
exitFullscreen();
}
};
// 浏览器原生全屏变化(ESC 退出),同步 React 状态
const onFsChange = () => {
if (!document.fullscreenElement && isFullscreen) {
setIsFullscreen(false);
}
};
if (isFullscreen) {
document.addEventListener('keydown', onKeyDown);
document.addEventListener('fullscreenchange', onFsChange);
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.removeEventListener('fullscreenchange', onFsChange);
document.body.classList.remove('monitoring-fullscreen-active');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFullscreen]);
const enterFullscreen = useCallback(() => {
setIsFullscreen(true);
// 浏览器远程访问环境:用原生 Fullscreen API 让 body 全屏
if (typeof (document.documentElement as any).requestFullscreen === 'function') {
(document.documentElement as any).requestFullscreen().catch(() => {});
} else if (typeof (document.body as any).webkitRequestFullscreen === 'function') {
(document.body as any).webkitRequestFullscreen();
}
}, []);
const exitFullscreen = useCallback(() => {
setIsFullscreen(false);
const nc = (window as any).netcatty;
if (nc?.windowFullscreen) {
nc.windowFullscreen(false).catch(() => {});
}
// 浏览器环境退出原生全屏
if (document.fullscreenElement && typeof document.exitFullscreen === 'function') {
document.exitFullscreen().catch(() => {});
} else if (typeof (document as any).webkitExitFullscreen === 'function') {
(document as any).webkitExitFullscreen();
}
}, []);
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]);
// 设备列表(status 优先取告警中心记录,兜底用本地阈值)
const devices = useMemo(() => {
// 从告警记录里提取每个设备的最严重告警级别(monitoring.alerts 已是 active 告警)
// 统一转字符串匹配,避免 device_id 是数字但 host.id 是字符串或反过来
const alertLevelByDevice = new Map<string, string>();
for (const a of monitoring.alerts) {
const key = String(a.deviceId);
const cur = alertLevelByDevice.get(key);
const rank = (lv: string) => lv === 'critical' ? 2 : lv === 'warning' ? 1 : 0;
if (!cur || rank(a.level) > rank(cur)) {
alertLevelByDevice.set(key, a.level);
}
}
return monitoring.effectiveHosts.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;
// 本地阈值(与主进程 alertRuleStore 默认规则对齐)
let localStatus: string;
if (!online) {
localStatus = 'offline';
} else if (cpuUsage > monitoring.thresholds.cpuCritical || memoryUsage > monitoring.thresholds.memCritical) {
localStatus = 'critical';
} else if (cpuUsage > monitoring.thresholds.cpuWarning || memoryUsage > monitoring.thresholds.memWarning) {
localStatus = 'warning';
} else {
localStatus = 'normal';
}
// 告警记录里的级别(统一 String 匹配)
const alertLevel = alertLevelByDevice.get(String(h.id));
// 离线 > 告警 critical > 告警 warning > 本地计算
let status = localStatus;
if (alertLevel === 'critical' && localStatus !== 'offline') {
status = 'critical';
} else if (alertLevel === 'warning' && localStatus === 'normal') {
status = 'warning';
}
// label 里可能已经包含 IP(如 "外网-192.168.0.221"),去重显示
let rawName = h.label || h.id;
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
const name = rawName.replace(ipRegex, '').trim().replace(/[-_\s]+$|^[-_\s]+/g, '') || rawName;
return {
id: h.id,
name,
ip: h.hostname,
type,
status,
model: '',
location: '',
online,
cpuUsage,
memoryUsage,
networkThroughput: 0,
lastUpdate,
};
});
}, [monitoring.effectiveHosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap, monitoring.alerts, monitoring.thresholds]);
// 合并自动生成链路和用户自定义链路,并过滤掉源/目标已不存在的僵尸连线
const links = useMemo(() => {
const deviceIds = new Set(devices.map((d) => d.id));
const autoLinks = autoGenerateLinks(devices)
.filter((l) => !excludedAutoLinks.some((e) => e.source === l.source && e.target === l.target))
.filter((l) => deviceIds.has(l.source) && deviceIds.has(l.target));
const validCustom = customLinks.filter((l) => deviceIds.has(l.source) && deviceIds.has(l.target));
// 只有在 devices 至少有数据后才执行清理,避免初始空 devices 状态下误删全部 customLinks
if (devices.length > 0 && validCustom.length !== customLinks.length) {
queueMicrotask(() => {
setCustomLinks(validCustom);
localStorage.setItem('monitoring-topology-links', JSON.stringify(validCustom));
});
}
return [...autoLinks, ...validCustom];
}, [devices, customLinks, excludedAutoLinks]);
// 统计
const stats = useMemo(() => {
const d = devices;
const normal = d.filter((x) => x.status === 'normal').length;
const warning = d.filter((x) => x.status === 'warning').length;
const criticalOnly = d.filter((x) => x.status === 'critical').length;
const offline = d.filter((x) => x.status === 'offline').length;
// 严重告警 = 离线设备 + 严重告警设备(离线属于 critical 级别告警)
const critical = criticalOnly + offline;
return {
total: d.length,
online: normal + warning + criticalOnly, // 所有非离线、非严重告警设备
offline,
warning,
critical,
normal,
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]);
// 活动告警(显示全部,滚动查看)
const activeAlarms = useMemo(() => {
const order = ['critical', 'major', 'warning', 'minor', 'info'];
return [...monitoring.alerts]
.sort((a, b) => order.indexOf(a.level) - order.indexOf(b.level));
}, [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 === 'normal') 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;
// 每次 setup 都 new Scene/Camera/Controls — 不复用。
// 旧 scene 在替换前必须完整 dispose 所有 GPU 资源(geometry/material/texture),
// 否则每次 tab 切换都泄漏一批 GPU buffer + label DOM,累积后渲染重叠 + 卡顿。
if (animIdRef.current) { cancelAnimationFrame(animIdRef.current); animIdRef.current = 0; }
if (composerRef.current) { try { composerRef.current.dispose(); } catch { /* ignore */ } composerRef.current = null; }
labelsRef.current.forEach((l) => l.remove());
labelsRef.current = [];
// 完整 dispose 旧 scene 的所有 GPU 资源
const oldScene = sceneRef.current;
if (oldScene) {
oldScene.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh || (obj as THREE.Line).isLine) {
const m = obj as any;
if (m.geometry) m.geometry.dispose?.();
const mat = m.material;
if (Array.isArray(mat)) mat.forEach((x: any) => disposeMatTextures(x));
else disposeMatTextures(mat);
}
// dispose CSS2DObject 的 DOM(CSS2DObject.element 不在 Object3D 类型声明里)
const cssEl = (obj as any).element;
if (cssEl && cssEl.parentNode) {
try { cssEl.parentNode.removeChild(cssEl); } catch { /* ignore */ }
}
});
// 断开旧 scene 与所有子节点的引用,让 GC 回收(避免 GPU buffer 泄漏)
while (oldScene.children.length > 0) oldScene.remove(oldScene.children[0]);
}
sceneRef.current = null;
nodesRef.current = [];
edgesRef.current = [];
linkBreakRef.current = [];
cameraRef.current = null;
controlsRef.current = null;
transformControlsRef.current = null;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x6fa8dc);
scene.fog = new THREE.Fog(0xcfe2f3, 120, 280);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(
60,
container.clientWidth / container.clientHeight,
0.1,
500
);
camera.position.set(0, 55, 115);
cameraRef.current = camera;
// 复用全局单例 WebGLRenderer —— 整个应用只创建一个 WebGL context
const renderer = getGlobalWebGLRenderer();
renderer.setSize(container.clientWidth, container.clientHeight);
// 全局单例的 domElement 可能挂在别的父节点,先 detach 再挂到这个 container
const glDom = renderer.domElement;
if (glDom.parentNode !== container) {
if (glDom.parentNode) glDom.parentNode.removeChild(glDom);
container.appendChild(glDom);
}
rendererRef.current = renderer;
// 后处理:径向模糊 — 中心清晰、两侧模糊
const RadialBlurShader = {
uniforms: {
tDiffuse: { value: null },
uResolution: { value: new THREE.Vector2(container.clientWidth, container.clientHeight) },
uRadius: { value: 0.95 }, // 仅最外圈模糊,大部分区域保持清晰
uMaxBlur: { value: 6.0 } // 边缘最大模糊 texel 数
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform vec2 uResolution;
uniform float uRadius;
uniform float uMaxBlur;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
// 归一化距离中心 (0,0),最外圈是 1.0
float dist = length(uv - vec2(0.5, 0.5)) * 2.0;
// 模糊因子:在 uRadius 内为 0,之外平滑过渡到 1
float blurFactor = smoothstep(uRadius, uRadius + 0.35, dist);
if (blurFactor < 0.01) {
gl_FragColor = texture2D(tDiffuse, uv);
} else {
// 9x9 box blur,半径随 blurFactor 增加
float step = blurFactor * uMaxBlur;
vec4 color = vec4(0.0);
float total = 0.0;
for (int x = -4; x <= 4; x++) {
for (int y = -4; y <= 4; y++) {
vec2 offset = vec2(float(x), float(y)) * step / uResolution;
color += texture2D(tDiffuse, uv + offset);
total += 1.0;
}
}
gl_FragColor = color / total;
}
}
`
};
const composer = new EffectComposer(renderer);
composer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
composer.setSize(container.clientWidth, container.clientHeight);
// RenderTarget 配置:RGBA8 + depth + 不透明(匹配 WebGL 默认)
composer.renderTarget1.texture.colorSpace = THREE.SRGBColorSpace;
composer.renderTarget2.texture.colorSpace = THREE.SRGBColorSpace;
const renderPass = new RenderPass(scene, camera);
renderPass.clear = true;
composer.addPass(renderPass);
const radialBlurPass = new ShaderPass(RadialBlurShader);
composer.addPass(radialBlurPass);
// 保存到 ref 供 resize 用
(composer as any).userData = { radialBlurPass };
composerRef.current = composer;
// CSS2D 标签渲染器 —— 全局单例
const labelRenderer = getGlobalCSS2DRenderer();
labelRenderer.setSize(container.clientWidth, container.clientHeight);
const labelDom = labelRenderer.domElement;
if (labelDom.parentNode !== container) {
if (labelDom.parentNode) labelDom.parentNode.removeChild(labelDom);
container.appendChild(labelDom);
}
labelRendererRef.current = labelRenderer;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = true;
controls.enableRotate = true;
controls.enableZoom = true;
controls.target.set(-20, -25, 20); // 往上抬 target,让顶部露出更多蓝天
controls.update();
controls.minDistance = 30;
controls.maxDistance = 150;
controls.maxPolarAngle = Math.PI / 2 - 0.1;
controls.minPolarAngle = 0;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.4;
controlsRef.current = controls;
// TransformControls 拖拽
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 }; // 使用对象引用避免闭包问题
// ===== 场景初始化:灯光/网格/天空球/地图纹理 =====
// 每次 init setup 都是全新 scene,直接加
// 灯光
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: 0xa5c8ec, // 更浅的灰蓝
transparent: true,
opacity: 0.025, // 更淡,几乎看不出来
side: THREE.DoubleSide,
depthWrite: false,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -0.2;
scene.add(ring);
// === CanvasTexture 渐变蓝天背景(ITYUNWEI 原版) ===
const cvs = document.createElement('canvas');
cvs.width = 2; cvs.height = 512;
const ctx = cvs.getContext('2d')!;
const skyGrad = ctx.createLinearGradient(0, 0, 0, 512);
skyGrad.addColorStop(0.00, '#1e4a7a');
skyGrad.addColorStop(0.30, '#3d7ab8');
skyGrad.addColorStop(0.65, '#6fa8dc');
skyGrad.addColorStop(0.88, '#cfe2f3');
skyGrad.addColorStop(1.00, '#e8e8d8');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, 2, 512);
const bgTex = new THREE.CanvasTexture(cvs);
bgTex.colorSpace = THREE.SRGBColorSpace;
scene.background = bgTex;
scene.fog = new THREE.Fog(0xcfe2f3, 120, 280);
// SkyDome - Shader 分段渐变穹顶(ITYUNWEI 原版色系)
const skyGeo = new THREE.SphereGeometry(300, 48, 32);
skyGeo.scale(-1, 1, 1);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
uTop: { value: new THREE.Color('#1e4a7a') },
uMid: { value: new THREE.Color('#3d7ab8') },
uSky: { value: new THREE.Color('#6fa8dc') },
uHorizon: { value: new THREE.Color('#cfe2f3') },
uBottom: { value: new THREE.Color('#e8e8d8') },
},
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.7) col = mix(uMid, uTop, smoothstep(0.7, 1.0, h)); // 70% 以上 → 深蓝天顶(窄带)
else if(h > 0.35) col = mix(uSky, uMid, smoothstep(0.35, 0.7, h)); // 35%-70% → 亮蓝天空
else if(h > 0.08) col = mix(uHorizon, uSky, smoothstep(0.08, 0.35, h)); // 8%-35% → 蓝转云白
else if(h > -0.04) col = mix(uBottom, uHorizon, smoothstep(-0.04, 0.08, h)); // 地平线 ±4% → 厚云层
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');
}
);
// 设备/连线创建已移到下方独立的 useEffect(依赖 [devices, links])
// 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(nodesRef.current, 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)) {
const newPos = intersection.add(dragOffset);
// 只更新 x/z,保留滚轮升降设置的 y
draggingNode.position.x = newPos.x;
draggingNode.position.z = newPos.z;
draggingNode.position.y = Math.max(MIN_Y, Math.min(MAX_Y, draggingNode.position.y));
}
};
// 拖拽结束:立即持久化到 positions Map 和 localStorage
const commitDevicePosition = (node: THREE.Group) => {
const devId = node.userData.deviceId as string;
const p = node.position;
const clampedY = Math.max(MIN_Y, Math.min(MAX_Y, p.y));
positionsRef.current[devId] = new THREE.Vector3(p.x, clampedY, p.z);
node.position.y = clampedY;
deviceBaseYRef.current.set(devId, clampedY);
// 同步写入 localStorage(不需要等组件卸载)
try {
const saved = localStorage.getItem('monitoring-topology-positions');
const data: Record<string, { x: number; y: number; z: number }> = saved ? JSON.parse(saved) : {};
data[devId] = { x: p.x, y: clampedY, z: p.z };
localStorage.setItem('monitoring-topology-positions', JSON.stringify(data));
// Electron 环境同步给主进程,让远程浏览器也能读到
const nc = (window as any).netcatty;
if (nc?.inspection?.saveTopologyPositions) {
nc.inspection.saveTopologyPositions(data).catch(() => {});
}
} catch { /* ignore */ }
};
const onPointerUp = (event: MouseEvent) => {
if (isDraggingDevice && draggingNode) {
console.log('[拖拽] 结束拖拽,新位置:', draggingNode.position.x, draggingNode.position.y, draggingNode.position.z);
commitDevicePosition(draggingNode);
isDraggingDevice = false;
draggingNode = null;
controls.enabled = true;
controls.autoRotate = true;
setIsDragging(false);
}
};
// 拖拽设备期间:滚轮升降高度
const onWheelDuringDrag = (event: WheelEvent) => {
if (!isDraggingDevice || !draggingNode) return;
event.preventDefault();
const delta = event.deltaY > 0 ? -0.5 : 0.5; // 滚轮向上抬,往下降
draggingNode.position.y = Math.max(MIN_Y, Math.min(MAX_Y, draggingNode.position.y + delta));
};
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(nodesRef.current, 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;
setDeleteLinkConfirm(null);
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(nodesRef.current, 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 nodeHits = raycaster.intersectObjects(nodesRef.current, true);
if (nodeHits.length > 0) {
const obj = nodeHits[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 });
return;
}
}
// 右键命中连线 → 删除提示
const edgeHits = raycaster.intersectObjects(edgesRef.current, false);
if (edgeHits.length > 0) {
const line = edgeHits[0].object as THREE.Line;
const sourceId = line.userData.sourceId as string;
const targetId = line.userData.targetId as string;
const isCustom = customLinks.some(
(l) => l.source === sourceId && l.target === targetId
);
setDeleteLinkConfirm({
x: event.clientX,
y: event.clientY,
sourceId,
targetId,
isCustom,
});
} else {
setContextMenu(null);
}
};
renderer.domElement.addEventListener('contextmenu', onContextMenu);
// OrbitControls 内部用 pointerdown(不是 mousedown),在 target 阶段注册。
// 我们必须用 pointerdown + capture 阶段先拦截,命中设备时先禁用 controls.enabled,
// OrbitControls 的 onPointerDown 第一行 if (this.enabled === false) return 就会直接跳过,
// 不会调 setPointerCapture 也不会注册后续 pointermove/pointerup,地图就不会跟着转。
renderer.domElement.addEventListener('pointerdown', onPointerDown, { capture: true });
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
window.addEventListener('wheel', onWheelDuringDrag, { passive: false });
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);
// Line2 的 material 需要更新 resolution
edgesRef.current.forEach((line) => {
(line.material as LineMaterial).resolution.set(w, h);
});
if (composerRef.current) {
composerRef.current.setSize(w, h);
const rbPass = (composerRef.current as any).userData?.radialBlurPass;
if (rbPass) {
rbPass.uniforms.uResolution.value.set(w, h);
}
}
labelRenderer.setSize(w, h);
chartsRef.current.forEach((c) => c.resize());
};
const resizeObs = new ResizeObserver(onResize);
resizeObs.observe(container);
// 动画循环
const timer = new THREE.Timer();
const animate = () => {
animIdRef.current = requestAnimationFrame(animate);
timer.update();
// === 相机平滑过渡动画(如果有)===
const anim = cameraAnimRef.current;
if (anim) {
// 过渡期间禁用 OrbitControls,避免 damping / autoRotate 和 lerp 抢 position
controls.enabled = false;
const now = performance.now();
let progress = Math.min(1, (now - anim.startMs) / anim.durationMs);
const eased = easeInOutCubic(progress);
// === 相机位置:绕 target 做球面插值(弧线),避免直线穿过 target ===
const fromTarget = anim.fromTarget;
const toTarget = anim.toTarget;
// 先插值 target(target 本身变化用直线 OK)
const curTarget = new THREE.Vector3().lerpVectors(fromTarget, toTarget, eased);
controls.target.copy(curTarget);
// 计算相机相对 target 的方向(fromDir → toDir 的球面插值)
const fromDir = new THREE.Vector3().subVectors(anim.fromPos, fromTarget).normalize();
const toDir = new THREE.Vector3().subVectors(anim.toPos, toTarget).normalize();
const fromDist = anim.fromPos.distanceTo(fromTarget);
const toDist = anim.toPos.distanceTo(toTarget);
const curDist = fromDist + (toDist - fromDist) * eased;
// 用四元数球面插值方向(避免直线穿过 target 的奇怪透视)
const fromQ = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, -1), fromDir);
const toQ = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, -1), toDir);
const curQ = fromQ.clone().slerp(toQ, eased); // 兼容写法
const curDir = new THREE.Vector3(0, 0, -1).applyQuaternion(curQ);
camera.position.copy(curTarget).addScaledVector(curDir, curDist);
// FOV 还是直线插值
camera.fov = anim.fromFov + (anim.toFov - anim.fromFov) * eased;
camera.updateProjectionMatrix();
if (progress >= 1) {
camera.position.copy(anim.toPos);
controls.target.copy(anim.toTarget);
camera.fov = anim.toFov;
cameraAnimRef.current = null;
controls.enabled = true; // 过渡结束恢复
anim.onComplete?.();
} else {
// 过渡中:跳过所有后续 controls.update / 连线 / 节点动画帧,只保留过渡
// 但节点呼吸灯和连线 dashOffset 也会停——算了,用户要的是"轨迹平滑"
// 我们只跳过 controls.update 就行,其他轻量动画继续
}
} else {
// 正常模式:确保 controls enabled
if (!controls.enabled) controls.enabled = true;
}
const t = Date.now() * 0.001;
const elapsed = timer.getElapsed();
// Critical 节点呼吸灯 + 选中节点高亮 + 运行时状态同步
nodesRef.current.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.style.color = s.text;
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) 轻微浮动 — 用 deviceBaseYRef 里的持久 baseY,确保不被覆盖
if (!(isDraggingDevice && draggingNode === n)) {
const baseY = deviceBaseYRef.current.get(n.userData.deviceId as string) ?? 0;
n.position.y = baseY + Math.sin(elapsed * 0.5 + i) * 0.15;
}
});
// 虚线流动 + 连线端点跟随设备位置(Line2 专用 setPositions API)
const nodeGroupMap = nodeGroupMapRef.current;
edgesRef.current.forEach((line, i) => {
const sourceId = line.userData.sourceId;
const targetId = line.userData.targetId;
// 用 Map.get 替代 Array.find,O(1) 查找
const sourceNode = nodeGroupMap.get(sourceId);
const targetNode = nodeGroupMap.get(targetId);
if (sourceNode && targetNode) {
const hashKey = line.userData.arcMidHash;
const { mid } = computeArcMidpoint(sourceNode.position, targetNode.position, hashKey);
// 复用预分配 Float32Array,避免每帧每条连线 new 数组
const pos = line.userData.posArray as Float32Array;
pos[0] = sourceNode.position.x; pos[1] = sourceNode.position.y; pos[2] = sourceNode.position.z;
pos[3] = mid.x; pos[4] = mid.y; pos[5] = mid.z;
pos[6] = targetNode.position.x; pos[7] = targetNode.position.y; pos[8] = targetNode.position.z;
(line.geometry as LineGeometry).setPositions(pos);
line.computeLineDistances();
}
const mat = line.material as LineMaterial;
mat.dashOffset = -((t * 2.5 + i * 0.05) % (mat.dashSize + mat.gapSize));
// 离线连线:至少一端 offline → 显示叉叉 + 连线变灰
const breakMark = linkBreakRef.current[i];
if (breakMark) {
const srcOffline = sourceNode?.userData.currentStatus === 'offline';
const tgtOffline = targetNode?.userData.currentStatus === 'offline';
const disconnected = srcOffline || tgtOffline;
breakMark.visible = disconnected;
if (disconnected && sourceNode && targetNode) {
const hashKey = line.userData.arcMidHash;
const { mid } = computeArcMidpoint(sourceNode.position, targetNode.position, hashKey);
breakMark.position.set(mid.x, mid.y + 1.5, mid.z);
breakMark.quaternion.copy(camera.quaternion);
}
// 离线连线颜色降饱和 + 更暗
if (disconnected) {
mat.color.setHex(0x556677);
mat.opacity = 0.55;
} else {
mat.color.setHex(edgeColorMap[line.userData.linkType] || 0x2196f3);
mat.opacity = 1.0;
}
}
});
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('pointerdown', onPointerDown, { capture: true });
window.removeEventListener('mousemove', onPointerMove);
window.removeEventListener('mouseup', onPointerUp);
window.removeEventListener('wheel', onWheelDuringDrag);
renderer.domElement.removeEventListener('click', onPointerClick);
renderer.domElement.removeEventListener('click', onBackgroundClick);
// 保存设备位置到 localStorage
try {
const posData: Record<string, { x: number; y: number; z: number }> = {};
nodesRef.current.forEach((n) => {
const id = n.userData.deviceId;
posData[id] = {
x: n.position.x,
y: Math.max(-0.5, Math.min(20, n.position.y)), // 保存时也 clamp
z: n.position.z,
};
});
localStorage.setItem('monitoring-topology-positions', JSON.stringify(posData));
} catch (e) {
console.warn('[Monitoring] Failed to save positions:', e);
}
nodesRef.current.forEach((g) => {
g.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh || (obj as THREE.Line).isLine) {
(obj as any).geometry?.dispose?.();
const mat = (obj as any).material;
if (Array.isArray(mat)) mat.forEach((m) => disposeMatTextures(m));
else disposeMatTextures(mat);
}
});
});
edgesRef.current.forEach((e) => {
e.geometry?.dispose();
disposeMatTextures((e as any).material);
});
labelsRef.current.forEach((l) => l.remove());
labelsRef.current = [];
if (composerRef.current) {
composerRef.current.dispose();
composerRef.current = null;
}
// 全局单例 renderer —— 只 detach DOM,不 dispose WebGL context
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
}, []); // 只在 mount/unmount 时跑,renderer 全局唯一
// ===== 设备/连线创建:每次 devices/links 变化重建 scene 内容,不重建 renderer =====
useEffect(() => {
const scene = sceneRef.current;
if (!scene) return;
// 守卫:设备 ID 集合 + 连线都没变,且 scene 里真的还有设备节点 → 跳过重建
// 注:scene 可能被 cleanup 清空过(nodesRef 变成空数组),这时即使 devices/links 没变也要重建
const sceneNeedsRebuild = nodesRef.current.length === 0;
const existingIds = new Set(nodesRef.current.map((n) => String(n.userData.deviceId)));
const newIds = new Set(devices.map((d) => String(d.id)));
const sameDevices = !sceneNeedsRebuild && existingIds.size === newIds.size && [...newIds].every((id) => existingIds.has(id));
const edgeKey = (src: string, tgt: string) => (src < tgt ? `${src}-${tgt}` : `${tgt}-${src}`);
const existingEdgeKeys = new Set(edgesRef.current.map((e) => edgeKey(e.userData.sourceId, e.userData.targetId)));
const newEdgeKeys = new Set(links.map((l) => edgeKey(l.source, l.target)));
const sameEdges = existingEdgeKeys.size === newEdgeKeys.size && [...newEdgeKeys].every((k) => existingEdgeKeys.has(k));
if (sameDevices && sameEdges) {
// 只更新 Map 引用,让 animate 循环用最新的 device 状态
deviceMapRef.current = new Map(devices.map((d) => [d.id, d]));
nodeGroupMapRef.current = new Map(nodesRef.current.map((n) => [String(n.userData.deviceId), n]));
return;
}
// 清理旧的 nodes/edges/labels(注意:必须 dispose 材质上的贴图,否则每次 rebuild 泄漏 GPU buffer)
nodesRef.current.forEach((g) => {
scene.remove(g);
g.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh || (obj as THREE.Line).isLine) {
(obj as any).geometry?.dispose?.();
const mat = (obj as any).material;
if (Array.isArray(mat)) mat.forEach((m) => disposeMatTextures(m));
else disposeMatTextures(mat);
}
});
});
edgesRef.current.forEach((e) => {
scene.remove(e);
e.geometry?.dispose();
disposeMatTextures((e as any).material);
});
linkBreakRef.current.forEach((b) => {
scene.remove(b);
(b as any).geometry?.dispose?.();
disposeMatTextures((b as any).material);
});
labelsRef.current.forEach((l) => l.remove());
nodesRef.current = [];
edgesRef.current = [];
linkBreakRef.current = [];
labelsRef.current = [];
deviceBaseYRef.current.clear();
if (devices.length === 0) return;
// === 设备布局(分层集群)===
deviceMapRef.current = new Map(devices.map((d) => [d.id, d]));
const positions: Record<string, THREE.Vector3> = {};
const savedPositions = (() => {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (IS_REMOTE) {
try {
const token = (window as any).__REMOTE_TOKEN__ || "";
const xhr = new XMLHttpRequest();
xhr.open("GET", `/api/topology-positions?token=${encodeURIComponent(token)}`, false);
xhr.send();
if (xhr.status === 200) {
const json = JSON.parse(xhr.responseText);
if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
return json.positions;
}
}
} catch { /* ignore */ }
}
// 本地 Electron:从 localStorage 读,找不到则从主进程 inspection-data.json 读
try {
let saved = localStorage.getItem('monitoring-topology-positions');
if (saved) return JSON.parse(saved);
// portable 版 localStorage 为空 → fallback 主进程 JSON(sendSync 同步)
try {
const nc = (window as any).netcatty;
const fromMain = nc?.inspection?.getTopologyPositionsSync?.();
if (fromMain && typeof fromMain === 'object' && Object.keys(fromMain).length > 0) {
console.info('[Monitoring] localStorage 无位置,从主进程 inspection-data.json 加载');
return fromMain;
}
} catch { /* ignore */ }
return 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]);
const MIN_Y = -0.5;
const MAX_Y = 20;
if (savedPositions) {
devices.forEach((d) => {
if (savedPositions[d.id]) {
positions[d.id] = new THREE.Vector3(
savedPositions[d.id].x,
Math.max(MIN_Y, Math.min(MAX_Y, savedPositions[d.id].y ?? 0)),
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
);
});
}
positionsRef.current = positions;
// Electron 环境:启动时自动把 localStorage 里的拓扑位置同步给主进程,供远程浏览器读取
{
const IS_ELECTRON = typeof window !== "undefined" && !!(window as any).netcatty;
if (IS_ELECTRON) {
try {
const saved = localStorage.getItem('monitoring-topology-positions');
if (saved) {
const nc = (window as any).netcatty;
nc.inspection?.saveTopologyPositions?.(JSON.parse(saved)).catch(() => {});
}
} catch { /* ignore */ }
}
}
// 创建设备节点
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);
deviceBaseYRef.current.set(device.id, pos.y);
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);
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: labelStyle.text, 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);
group.add(labelObj);
group.userData.labelDiv = labelDiv;
labelsRef.current.push(labelDiv);
}
nodesRef.current = nodes;
// 创建链路
const edges: Line2[] = [];
const breaks: THREE.Group[] = [];
for (const link of links) {
const a = positions[link.source];
const b = positions[link.target];
if (!a || !b) continue;
const { mid } = computeArcMidpoint(a, b, link.source + '_' + link.target);
const color = edgeColorMap[link.type] || 0x2196f3;
const geo = new LineGeometry();
geo.setPositions([a.x, a.y, a.z, mid.x, mid.y, mid.z, b.x, b.y, b.z]);
const mat = new LineMaterial({
color, transparent: true, opacity: 1.0, linewidth: 3, worldUnits: false,
vertexColors: false, dashed: true, dashSize: 4, gapSize: 2.5,
resolution: new THREE.Vector2(window.innerWidth, window.innerHeight), depthTest: false,
});
const line = new Line2(geo, mat);
line.computeLineDistances();
line.renderOrder = 2;
// 预分配 Float32Array,animate 循环里复用避免每帧 GC
const posArray = new Float32Array(9);
line.userData = { sourceId: link.source, targetId: link.target, linkType: link.type, arcMidHash: link.source + '_' + link.target, posArray };
scene.add(line);
edges.push(line);
const breakX = createBreakMark();
breakX.position.set(mid.x, mid.y + 1.5, mid.z);
breakX.visible = false;
scene.add(breakX);
breaks.push(breakX);
}
edgesRef.current = edges;
linkBreakRef.current = breaks;
// 同步更新 deviceId → Group 查找表(给 animate 循环 O(1) 查找用)
nodeGroupMapRef.current = new Map(nodes.map((n) => [n.userData.deviceId as string, n]));
}, [devices, links]);
// 设备状态实时同步:每次 devices 变化更新 ref,动画循环读取最新 status
useEffect(() => {
deviceMapRef.current = new Map(devices.map((d) => [d.id, d]));
}, [devices]);
// ===== ECharts 初始化(页面首次加载 + 退出大屏后都要 init 一次) =====
// 大屏模式下 aside 被条件隐藏卸载,退出时 DOM 重建 → ref 拿到新节点 → 必须重新 init
useEffect(() => {
// 大屏模式下 aside 不存在,跳过
if (isTopologyOnly) {
chartsRef.current.forEach((c) => c.dispose());
chartsRef.current = [];
return;
}
const timer = setTimeout(() => {
chartsRef.current.forEach((c) => c.dispose());
chartsRef.current = [];
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 typeData = [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: 10 } },
xAxis: { type: 'category', data: names, axisLine: { lineStyle: { color: 'rgba(255,255,255,0.25)' } }, axisTick: { show: false }, axisLabel: { color: '#FFFFFF', fontSize: 8, interval: 0, rotate: 20 } },
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } }, axisLabel: { color: '#FFFFFF', fontSize: 10 }, minInterval: 1 },
series: [{ type: 'bar', data: typeData, barWidth: '45%',
label: { show: true, position: 'top', color: '#38bdf8', fontSize: 9, 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: 10 } },
xAxis: { type: 'category', data: trend.map((t) => t.date), axisLine: { lineStyle: { color: 'rgba(255,255,255,0.25)' } }, axisTick: { show: false }, axisLabel: { color: '#FFFFFF', fontSize: 8 } },
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#1e293b', type: 'dashed' } }, axisLabel: { color: '#FFFFFF', fontSize: 10 }, 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);
setTimeout(resizeAll, 600);
}, 80);
return () => {
clearTimeout(timer);
chartsRef.current.forEach((c) => c.dispose());
chartsRef.current = [];
};
}, [isTopologyOnly]); // 首次加载 + 退出大屏时都重新 init
// ===== 数据更新:不 dispose 图表,只用 setOption 更新 data(无闪动)=====
useEffect(() => {
const [typeChart, trendChart] = chartsRef.current;
if (!typeChart || !trendChart) return;
const names = ['服务器', '交换机', '路由器', '防火墙', '存储', '工作站'];
const typeData = [typeDistribution.server, typeDistribution.switch, typeDistribution.router, typeDistribution.firewall, typeDistribution.storage, typeDistribution.workstation];
// notMerge=false → 只合并改动部分,保留其他配置
typeChart.setOption({ xAxis: { data: names }, series: [{ data: typeData }] }, { notMerge: false });
const trend = monitoring.alarmTrend;
trendChart.setOption({
xAxis: { data: trend.map((t) => t.date) },
series: [{ data: trend.map((t) => t.count) }],
}, { notMerge: false });
}, [typeDistribution, monitoring.alarmTrend]);
const resetView = useCallback(() => {
if (cameraRef.current && controlsRef.current) {
cameraRef.current.position.set(0, 80, 125);
controlsRef.current.target.set(-20, -40, 20);
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 {
font-size: 17px;
--m-bg-dark: #164172;
--m-bg-card: #1E5B96;
--m-bg-panel: #2B73B8;
--m-bg-hover: #3d4f66;
--m-border: rgba(125,211,252,0.35);
--m-border-accent: #5AB0FF;
--m-text-primary: #FFFFFF;
--m-text-secondary: #E0F2FE;
--m-text-muted: #BAE6FD;
--m-primary: #38BDF8;
--m-primary-light: #7DD3FC;
--m-accent: #22d3ee;
--m-success: #00FF66;
--m-warning: #FACC15;
--m-danger: #FF4D4F;
--m-offline: #CBD5E1;
}
.monitoring-page .text-slate-300 { color: #FFFFFF !important; }
.monitoring-page .text-slate-400 { color: #FFFFFF !important; }
.monitoring-page .text-slate-500 { color: #E0F2FE !important; }
.monitoring-page .text-muted-foreground { color: #FFFFFF !important; }
.monitoring-page .text-foreground { color: #FFFFFF !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 .border { border-color: rgba(148,163,184,0.15) !important; }
.monitoring-page { color: #FFFFFF; }
/* 强制覆盖 slate/gray/zinc/stone 灰系文字为纯白 */
.monitoring-page [class*="text-slate-"] { color: #FFFFFF !important; }
.monitoring-page [class*="text-gray-"] { color: #FFFFFF !important; }
.monitoring-page [class*="text-zinc-"] { color: #FFFFFF !important; }
.monitoring-page [class*="text-stone-"] { color: #FFFFFF !important; }
/* 全屏模式: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)]'
}`}
style={{
background: '#164172',
}}
>
{/* 3D 拓扑底图 — 覆盖整个页面 */}
<div ref={containerRef} className="absolute inset-0 w-full h-full z-0" />
<div
className="relative z-10 grid"
style={{
display: 'grid',
gridTemplateRows: isTopologyOnly ? '1fr' : 'auto 1fr',
gridTemplateColumns: isTopologyOnly
? 'minmax(0, 1fr)'
: isFullscreen
? 'clamp(225px, 18vw, 355px) minmax(0, 1fr) clamp(225px, 18vw, 355px)'
: 'clamp(210px, 16vw, 320px) minmax(0, 1fr) clamp(210px, 16vw, 320px)',
height: '100%',
columnGap: '8px',
rowGap: '8px',
pointerEvents: 'none',
}}
>
{/* 顶部左 — 设备状态统计 */}
{!isTopologyOnly && (
<div
className="z-50 flex items-center justify-center w-full gap-2 px-2 py-1 rounded-md whitespace-nowrap overflow-hidden min-w-0"
style={{
background: 'linear-gradient(90deg, rgba(22,65,114,0.72) 0%, rgba(30,91,150,0.55) 100%)',
borderBottom: '2px solid #5AB0FF',
borderTop: '1px solid rgba(90,176,255,0.5)',
borderLeft: '1px solid rgba(90,176,255,0.5)',
borderRight: '1px solid rgba(90,176,255,0.5)',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
boxShadow: '0 2px 10px rgba(56,189,248,0.15)',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translateY(-120%)' : 'translateY(0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-[#00FF66]" />
<span className="text-white">正常</span>
<span className="font-bold text-[#00FF66]">{stats.normal}</span>
</div>
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
<span className="text-white">预警</span>
<span className="font-bold text-amber-300">{stats.warning}</span>
</div>
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
<span className="text-white">故障</span>
<span className="font-bold text-red-400">{stats.critical}</span>
</div>
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-slate-400" />
<span className="text-white">离线</span>
<span className="font-bold text-slate-200">{stats.offline}</span>
</div>
<div className="h-3 w-px bg-border flex-shrink-0" />
<div className="flex items-center gap-0.5 text-[11px] flex-shrink-0">
<Server className="w-3 h-3 text-cyan-300" />
<span className="text-white">{stats.total} · {stats.linkCount}</span>
</div>
</div>
)}
{/* 顶部中 — 系统标题 */}
{!isTopologyOnly && (
<div
className="z-50 flex items-center justify-center select-none pointer-events-none"
style={{
padding: '2px 12px',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translateY(-120%)' : 'translateY(0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
<div
className="flex-1 h-px"
style={{
background: 'linear-gradient(to right, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
marginRight: '12px',
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
}}
/>
<div
className="flex items-center"
style={{
padding: '2px 28px',
borderTop: '1px solid #4A9BFF',
borderBottom: '1px solid #4A9BFF',
background: 'linear-gradient(180deg, rgba(58,123,213,0.22) 0%, rgba(58,123,213,0.06) 100%)',
boxShadow: '0 0 16px rgba(74,155,255,0.3), inset 0 0 12px rgba(74,155,255,0.15)',
}}
>
<h1
className="text-3xl font-bold"
style={{
background: 'linear-gradient(180deg, #FFFFFF 0%, #E0F2FE 50%, #7DD3FC 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
filter: 'drop-shadow(0 0 12px rgba(125,211,252,0.7))',
letterSpacing: '0.22em',
whiteSpace: 'nowrap',
}}
>
{monitoringTitle}
</h1>
</div>
<div
className="flex-1 h-px"
style={{
background: 'linear-gradient(to left, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
marginLeft: '12px',
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
}}
/>
</div>
)}
{/* 顶部右 — 告警/时间/按钮 */}
{!isTopologyOnly && (
<div
className="z-50 flex items-center justify-center w-full gap-2 px-2 py-1 rounded-md whitespace-nowrap"
style={{
background: 'linear-gradient(270deg, rgba(22,65,114,0.72) 0%, rgba(30,91,150,0.55) 100%)',
borderBottom: '2px solid #5AB0FF',
borderTop: '1px solid rgba(90,176,255,0.5)',
borderLeft: '1px solid rgba(90,176,255,0.5)',
borderRight: '1px solid rgba(90,176,255,0.5)',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
boxShadow: '0 2px 10px rgba(6,182,212,0.10)',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translateY(-120%)' : 'translateY(0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
{alarmCounts.total > 0 && (
<div
className="px-2 py-0.5 rounded animate-pulse flex items-center gap-1 text-[11px] font-bold cursor-pointer hover:opacity-80 transition-opacity"
style={{
background: 'linear-gradient(90deg, rgba(255,77,79,0.35) 0%, rgba(255,77,79,0.18) 100%)',
color: '#FF7875',
border: '1.5px solid rgba(255,77,79,0.75)',
boxShadow: '0 0 12px rgba(255,77,79,0.55), inset 0 0 8px rgba(255,77,79,0.25)',
textShadow: '0 0 6px rgba(255,77,79,0.6)',
}}
onClick={() => openAlertList('告警', 'all')}
>
⚠ {alarmCounts.total} 告警
</div>
)}
<div className="flex items-center gap-1 text-[11px] font-mono">
<Clock className="w-3 h-3 text-cyan-400" />
<span>{lastDataUpdate ? formatRelativeTime(lastDataUpdate) : '暂无数据'}</span>
</div>
<button
onClick={() => {
if (linkModeUI) {
setLinkMode(null);
} else {
setLinkMode({ sourceId: '' });
}
}}
className={`p-1 rounded transition-colors ${
linkModeUI
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/40'
: 'hover:bg-slate-700 text-slate-300'
}`}
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>
</button>
<button
onClick={toggleTopologyOnly}
className={`p-1 rounded transition-colors ${
isTopologyOnly
? 'bg-cyan-500/30 text-cyan-300 border border-cyan-400/50'
: 'hover:bg-slate-700 text-slate-300'
}`}
title={isTopologyOnly ? '退出大屏展示' : '3D拓扑大屏展示'}
>
<Network className="w-3.5 h-3.5" />
</button>
<button
onClick={toggleMute}
className={`p-1 rounded transition-colors ${
isMuted
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30'
: 'text-cyan-300 hover:bg-slate-700'
}`}
title={isMuted ? '已静音 — 点击开启告警提示音' : '点击静音告警提示音'}
>
{isMuted ? (
<VolumeX className="w-3 h-3" />
) : (
<Volume2 className="w-3 h-3" />
)}
</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-slate-300" />
) : (
<Expand className="w-3.5 h-3.5 text-slate-300" />
)}
</button>
</div>
)}
{/* 左栏 — 向左侧上方滑出 */}
{!isTopologyOnly && (
<aside
className="relative min-w-0 min-h-0 grid w-full h-full gap-2 p-2 overflow-hidden"
style={{
gridTemplateRows: 'minmax(0, auto) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)',
background: 'linear-gradient(to right, #164172 0%, #1E5B96 35%, #3E8BE0 100%)',
borderRight: '1px solid #5AB0FF',
boxShadow: 'inset -25px 0 50px rgba(90,176,255,0.25), inset 0 0 30px rgba(22,65,114,0.4)',
borderRadius: '0 8px 8px 0',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translate(-120%, -20%)' : 'translate(0, 0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
{/* 设备概览 */}
<div className="card rounded-xl border p-2 min-h-0 overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={Server} title="设备运行概览" iconColor="#67e8f9" iconBg="rgba(14,165,233,0.25)" />
<div className="grid grid-cols-2 gap-1.5 text-xs">
{[
{ label: '总数', value: stats.total, icon: Box, bg: 'bg-muted/50', filter: 'all', clickable: true, type: 'device' },
{ label: '正常', value: stats.normal, icon: CheckCircle2, bg: 'bg-[#00FF66]/15', color: 'text-[#00FF66]', iconColor: '#00FF66', filter: 'normal', clickable: true, type: 'device' },
{ label: '链路', value: stats.linkCount, icon: Link2, bg: 'bg-muted/50', clickable: false },
{ label: '严重', value: stats.critical, icon: AlertOctagon, bg: 'bg-red-500/10', color: 'text-red-400', iconColor: '#f87171', filter: 'critical', clickable: true, type: 'device' },
{ label: '离线', value: stats.offline, icon: WifiOff, bg: 'bg-slate-500/10', color: 'text-slate-400', iconColor: '#94a3b8', filter: 'offline', clickable: true, type: 'device' },
{ label: '警告', value: stats.warning, icon: AlertTriangle, bg: 'bg-amber-500/10', color: 'text-amber-400', iconColor: '#fbbf24', filter: 'warning', 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' : ''}`}
style={{ border: '1px solid rgba(125,211,252,0.08)' }}
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="flex items-center gap-1 min-w-0">
<item.icon className="w-2.5 h-2.5 flex-shrink-0" style={{ color: (item as any).iconColor || '#94a3b8' }} />
<span className={`truncate text-[11px] ${item.color || 'text-slate-400'}`}>{item.label}</span>
</div>
<span className={`text-base font-bold tabular-nums ${item.color || 'text-slate-100'}`}>{item.value}</span>
</div>
))}
</div>
</div>
{/* 设备类型分布 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={PieChart} title="设备类型分布" iconColor="#67e8f9" iconBg="rgba(14,165,233,0.25)" />
<div ref={chartTypeRef} className="w-full flex-1 min-w-0 min-h-0 overflow-hidden" />
</div>
{/* CPU 负载 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={Cpu} title="CPU 负载" iconColor="#fbbf24" iconBg="rgba(245,158,11,0.22)" extra={<span className="text-[11px] text-slate-400">{cpuList.length} 台</span>} />
<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-slate-300">暂无巡检数据</div>
)}
{cpuList.map((dev) => {
const DevIcon = getDeviceIcon(dev.type);
const statusDot = dev.cpuUsage > monitoring.thresholds.cpuCritical ? '#FF4D4F' : dev.cpuUsage > monitoring.thresholds.cpuWarning ? '#FACC15' : '#00FF66';
return (
<div
key={dev.id}
className="flex items-center gap-1.5 p-1.5 rounded cursor-pointer hover:bg-cyan-500/10 transition-colors -mx-1.5"
style={{ background: 'rgba(14,165,233,0.05)' }}
onClick={() => openDeviceDetail(dev.id)}
>
<div
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(14,165,233,0.18)', border: '1px solid rgba(103,232,249,0.25)' }}
>
<DevIcon className="w-2.5 h-2.5" style={{ color: '#67e8f9' }} />
</div>
<span className="text-[11px] truncate flex-shrink-0 font-medium" title={dev.name}>{dev.name}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-0.5 min-w-0 flex-shrink-0">
<span className="opacity-50">·</span>
<span className="truncate">{dev.ip || ''}</span>
<span className="opacity-40">{formatRelativeTime(dev.lastUpdate)}</span>
</span>
<div className="flex items-center gap-1 ml-auto flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: statusDot, boxShadow: `0 0 6px ${statusDot}` }} />
<span
className="text-xs font-bold font-mono tabular-nums leading-none"
style={{ color: dev.cpuUsage > monitoring.thresholds.cpuCritical ? '#f87171' : dev.cpuUsage > monitoring.thresholds.cpuWarning ? '#fbbf24' : '#6ee7b7' }}
>
{dev.cpuUsage}%
</span>
</div>
</div>
);
})}
</div>
</div>
{/* 内存负载 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={HardDrive} title="内存负载" iconColor="#c084fc" iconBg="rgba(168,85,247,0.22)" extra={<span className="text-[11px] text-slate-400">{memoryList.length} 台</span>} />
<div className="flex-1 min-h-0 overflow-auto space-y-1.5 pr-1">
{memoryList.length === 0 && (
<div className="text-[11px] text-center py-2 text-slate-300">暂无巡检数据</div>
)}
{memoryList.map((dev) => {
const DevIcon = getDeviceIcon(dev.type);
const statusDot = dev.memoryUsage > monitoring.thresholds.memCritical ? '#FF4D4F' : dev.memoryUsage > monitoring.thresholds.memWarning ? '#FACC15' : '#00FF66';
return (
<div
key={dev.id}
className="flex items-center gap-1.5 p-1.5 rounded cursor-pointer hover:bg-purple-500/10 transition-colors -mx-1.5"
style={{ background: 'rgba(168,85,247,0.05)' }}
onClick={() => openDeviceDetail(dev.id)}
>
<div
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(168,85,247,0.18)', border: '1px solid rgba(192,132,252,0.25)' }}
>
<DevIcon className="w-2.5 h-2.5" style={{ color: '#c084fc' }} />
</div>
<span className="text-[11px] truncate flex-shrink-0 font-medium" title={dev.name}>{dev.name}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-0.5 min-w-0 flex-shrink-0">
<span className="opacity-50">·</span>
<span className="truncate">{dev.ip || ''}</span>
<span className="opacity-40">{formatRelativeTime(dev.lastUpdate)}</span>
</span>
<div className="flex items-center gap-1 ml-auto flex-shrink-0">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: statusDot, boxShadow: `0 0 6px ${statusDot}` }} />
<span
className="text-xs font-bold font-mono tabular-nums leading-none"
style={{ color: dev.memoryUsage > monitoring.thresholds.memCritical ? '#f87171' : dev.memoryUsage > monitoring.thresholds.memWarning ? '#fbbf24' : '#6ee7b7' }}
>
{dev.memoryUsage}%
</span>
</div>
</div>
);
})}
</div>
</div>
</aside>
)}
{/* 中间主视区 — 操作浮层 */}
<section
className="relative overflow-hidden h-full min-h-0"
style={{
background: 'transparent',
pointerEvents: 'none',
}}
>
{/* loading 遮罩已移到页面外,3D 底图在最外层 */}
{/* Canvas container 已移到根 div,这里不再单独渲染 */}
{loading && (
<div
className="absolute inset-0 flex items-center justify-center z-10"
style={{ background: 'rgba(11,17,32,0.85)', pointerEvents: 'auto' }}
>
<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-xs text-slate-300">加载 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(18, 35, 60, 0.35)',
borderColor: 'rgba(96,165,250,0.30)',
backdropFilter: 'blur(8px)',
}}
>
<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-slate-300 hover:text-foreground">
&times;
</button>
</div>
<div className="space-y-1">
{[
{ color: 'bg-[#00FF66]', 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-slate-300">
<span className={`h-2 w-2 rounded-full ${it.color}`} /> {it.label}
</div>
))}
</div>
</div>
)}
{/* 连接线图例 — 右上侧,与左侧图例样式一致,大屏模式保留 */}
{showConnectionLegend && (
<div
className="absolute right-3 z-20 rounded-lg border p-3 pointer-events-auto"
style={{
top: isTopologyOnly ? '64px' : '12px',
background: 'rgba(18, 35, 60, 0.35)',
borderColor: 'rgba(96,165,250,0.30)',
backdropFilter: 'blur(8px)',
}}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-foreground">连接类型</span>
<button onClick={() => setShowConnectionLegend(false)} className="text-xs text-slate-300 hover:text-foreground">
&times;
</button>
</div>
<div className="space-y-1">
{[
{ label: 'WAN/上行', color: '#2196f3' },
{ label: 'L3 路由', color: '#4caf50' },
{ label: 'L2 交换', color: '#00bcd4' },
{ label: '存储', color: '#ffa500' },
{ label: '接入', color: '#9c27b0' },
{ label: '断开', isBreak: true },
].map((it) => (
<div key={it.label} className="flex items-center gap-1.5 text-xs text-slate-300">
{it.isBreak ? (
<span className="relative inline-flex items-center justify-center w-2.5 h-2.5 rounded-full bg-slate-500">
<span className="absolute w-2 h-[2px] bg-red-500 rotate-45" />
<span className="absolute w-2 h-[2px] bg-red-500 -rotate-45" />
</span>
) : (
<span className="h-2 w-2 rounded-full flex-shrink-0" style={{ background: it.color }} />
)}
{it.label}
</div>
))}
</div>
</div>
)}
{/* 底部控制栏 — 向下滑出 */}
{!isTopologyOnly && (
<div
className="absolute bottom-3 left-1/2 flex items-center gap-2 z-20"
style={{
pointerEvents: isPanelsHidden ? 'none' : 'auto',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: `translateX(-50%) ${isPanelsHidden ? 'translateY(120%)' : 'translateY(0)'}`,
opacity: isPanelsHidden ? 0 : 1,
}}
>
<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: 'rgba(15, 23, 42, 0.9)',
backdropFilter: 'blur(8px)',
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>
)}
{/* 右下角提示 — 向下滑出 */}
{!isTopologyOnly && (
<div
className="absolute bottom-3 right-3 z-20 text-xs 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)',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translateY(120%)' : 'translateY(0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
🖱️ 滚轮缩放 · 拖拽旋转 · 点击设备查看详情
</div>
)}
{/* 大屏模式 — 浮动退出按钮 */}
{isTopologyOnly && (
<button
onClick={toggleTopologyOnly}
className="absolute top-4 right-4 z-[100] px-4 py-2 rounded-lg flex items-center gap-2 transition-all hover:scale-105"
style={{
pointerEvents: 'auto',
background: 'linear-gradient(135deg, rgba(22,65,114,0.92) 0%, rgba(30,91,150,0.85) 100%)',
border: '1px solid #5AB0FF',
boxShadow: '0 4px 16px rgba(90,176,255,0.35), 0 0 20px rgba(90,176,255,0.15)',
color: '#E0F2FE',
fontWeight: 600,
fontSize: '13px',
letterSpacing: '0.05em',
}}
title="退出大屏展示模式"
>
<Maximize2 className="w-4 h-4 text-cyan-300" />
展开
</button>
)}
</section>
{/* 右栏 — 向右侧下方滑出 */}
{!isTopologyOnly && (
<aside
className="relative min-w-0 min-h-0 grid w-full h-full gap-2 p-2 overflow-hidden"
style={{
gridTemplateRows: 'minmax(0, auto) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)',
background: 'linear-gradient(to left, #164172 0%, #1E5B96 35%, #3E8BE0 100%)',
borderLeft: '1px solid #5AB0FF',
boxShadow: 'inset 25px 0 50px rgba(90,176,255,0.25), inset 0 0 30px rgba(22,65,114,0.4)',
borderRadius: '8px 0 0 8px',
pointerEvents: isPanelsHidden ? 'none' : 'auto',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease',
transform: isPanelsHidden ? 'translate(120%, 20%)' : 'translate(0, 0)',
opacity: isPanelsHidden ? 0 : 1,
}}
>
{/* 巡检任务状态 */}
<div className="card rounded-xl border p-2 min-h-0 overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={ListTodo} title="巡检任务" iconColor="#67e8f9" iconBg="rgba(14,165,233,0.25)" />
<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: '#00FF66',
bg: 'rgba(0,255,102,0.18)',
},
{
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: '#FFFFFF',
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-5 h-5 mx-auto rounded-full flex items-center justify-center mb-0.5"
style={{ background: it.bg }}
>
<it.icon className="w-2.5 h-2.5" style={{ color: it.color }} />
</div>
<div className="text-[11px] font-semibold leading-tight" style={{ color: it.color }}>
{it.value}
</div>
<div className="text-[10px] text-slate-300">{it.label}</div>
</div>
))}
</div>
</div>
{/* 告警趋势 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={TrendingUp} title="告警趋势 14 日" iconColor="#fbbf24" iconBg="rgba(245,158,11,0.22)" />
<div ref={chartTrendRef} className="w-full flex-1 min-w-0 min-h-0 overflow-hidden" />
</div>
{/* 离线设备 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader icon={WifiOff} title="离线设备" iconColor="#94a3b8" iconBg="rgba(148,163,184,0.22)" extra={<span className="text-[11px] text-slate-400">{offlineDevices.length} 台</span>} />
<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-[#00FF66]">
全部设备在线
</div>
)}
{offlineDevices.map((d) => {
const DevIcon = getDeviceIcon(d.type);
return (
<div
key={d.id}
className="flex items-center gap-1.5 p-1.5 rounded cursor-pointer transition-colors hover:bg-slate-500/15"
style={{ background: 'rgba(148,163,184,0.06)' }}
onClick={() => openDeviceDetail(d.id)}
>
<div
className="w-4 h-4 rounded flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(148,163,184,0.2)', border: '1px solid rgba(156,163,175,0.3)' }}
>
<DevIcon className="w-2.5 h-2.5" style={{ color: '#cbd5e1' }} />
</div>
<span className="text-[11px] truncate flex-shrink-0 text-slate-300 font-medium" title={d.name}>{d.name}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-0.5 min-w-0 flex-shrink-0">
<span className="opacity-50">·</span>
<span className="truncate">{d.ip || '-'}</span>
</span>
<span className="text-[10px] text-slate-500 flex items-center gap-0.5 min-w-0 flex-shrink-0">
<span>{formatRelativeTime(d.lastUpdate)}</span>
</span>
<span className="ml-auto text-[10px] px-1 py-0.5 rounded flex-shrink-0 font-semibold" style={{ color: '#fca5a5', background: 'rgba(239,68,68,0.18)', border: '1px solid rgba(239,68,68,0.55)' }}>离线</span>
</div>
);
})}
</div>
</div>
{/* 实时告警 */}
<div className="card rounded-xl border p-2 min-h-0 flex flex-col overflow-hidden" style={{ background: 'linear-gradient(135deg, rgba(26,61,102,0.78) 0%, rgba(39,90,144,0.68) 100%)', border: '1px solid #4A9BFF', backdropFilter: 'blur(12px)', pointerEvents: 'auto', boxShadow: '0 4px 16px rgba(15,40,71,0.4), inset 0 1px 0 rgba(125,211,252,0.2), inset 0 -1px 0 rgba(15,40,71,0.3)' }}>
<CardHeader
icon={activeAlarms.length > 0 ? AlertTriangle : CheckCircle2}
title="实时告警"
iconColor={activeAlarms.length > 0 ? '#f87171' : '#00FF66'}
iconBg={activeAlarms.length > 0 ? 'rgba(239,68,68,0.22)' : 'rgba(16,185,129,0.22)'}
extra={
alarmCounts.total > 0
? <span className="text-[11px] px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">{alarmCounts.total}</span>
: <span className="text-[11px] text-[#00FF66]">系统正常</span>
}
/>
{activeAlarms.length > 0 ? (
<div className="flex-1 min-h-0 overflow-auto space-y-1 pr-1">
{activeAlarms.map((alarm) => {
const lvlColor =
alarm.level === 'critical' ? '#FF4D4F'
: alarm.level === 'warning' ? '#FACC15'
: alarm.level === 'major' ? '#f97316'
: alarm.level === 'minor' ? '#fbbf24'
: '#60a5fa';
const LvlIcon =
alarm.level === 'critical' ? AlertOctagon
: (alarm.level === 'warning' || alarm.level === 'major' || alarm.level === 'minor') ? AlertTriangle
: Info;
return (
<div
key={alarm.id}
className="px-1.5 py-1 rounded text-[11px] border-l-2 -mx-1 cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => openDeviceDetail(alarm.deviceId)}
style={{
background: alarm.level === 'critical' ? 'rgba(239,68,68,0.10)'
: alarm.level === 'major' ? 'rgba(249,115,22,0.10)'
: alarm.level === 'warning' ? 'rgba(245,158,11,0.10)'
: 'rgba(96,165,250,0.08)',
borderLeftColor: lvlColor,
}}
>
<div className="flex items-center gap-1.5">
<div
className="w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0"
style={{ background: `${lvlColor}22`, border: `1px solid ${lvlColor}55` }}
>
<LvlIcon className="w-2 h-2" style={{ color: lvlColor }} />
</div>
<span
className="font-medium truncate flex-1"
style={{ color: lvlColor }}
title={`${alarm.deviceName} — ${alarm.message}`}
>
{alarm.message}
</span>
<span className="flex-shrink-0 font-mono text-[10px]" style={{ color: '#FFFFFF' }}>{alarm.time}</span>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-3">
<div
className="w-6 h-6 mx-auto rounded-full flex items-center justify-center mb-1"
style={{
background: 'rgba(0,255,102,0.18)',
border: '1px solid rgba(16,185,129,0.25)',
}}
>
<CheckCircle2 className="w-3 h-3 text-[#00FF66]" />
</div>
<div className="text-[11px] text-[#00FF66]">当前无活动告警</div>
<div className="text-[10px] mt-0.5 text-slate-300">设备运行一切正常</div>
</div>
)}
</div>
</aside>
)}
</div>{/* 内层 grid 结束 */}
{/* 设备详情弹窗 */}
{/* 连线类型选择对话框 */}
{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-xs 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: '#00FF66' },
{ type: 'storage', label: '存储', color: '#FACC15' },
{ 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);
setLinkMode(null); // 退出连线模式,恢复 cursor 为 grab
}}
>
{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>
)}
{/* 连线删除确认 */}
{deleteLinkConfirm && (
<div
className="fixed z-[9999] rounded-xl shadow-2xl py-2 min-w-[240px]"
style={{
left: deleteLinkConfirm.x,
top: deleteLinkConfirm.y,
background: 'rgba(15,23,42,0.98)',
border: '1px solid rgba(239,68,68,0.35)',
backdropFilter: 'blur(8px)',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="px-3 py-2 border-b border-slate-700/50 text-xs text-slate-200 flex items-center gap-2">
<svg className="w-4 h-4 text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
删除连线
</div>
<div className="px-3 py-2 text-xs text-slate-400 leading-relaxed">
确定删除这条连线?
{!deleteLinkConfirm.isCustom && (
<div className="mt-1 text-[12px] text-amber-400">(系统自动生成的连线,删除后将不再自动重建)</div>
)}
</div>
<div className="flex gap-1 px-2 pb-2">
<button
className="flex-1 px-2 py-1.5 rounded-md text-xs font-medium bg-red-500/90 hover:bg-red-500 text-white transition-colors"
onClick={() => {
const { sourceId, targetId, isCustom } = deleteLinkConfirm;
if (isCustom) {
setCustomLinks((prev) =>
prev.filter((l) => !(l.source === sourceId && l.target === targetId))
);
} else {
setExcludedAutoLinks((prev) => [
...prev,
{ id: `excluded-${sourceId}-${targetId}`, source: sourceId, target: targetId, type: 'excluded' },
]);
}
setDeleteLinkConfirm(null);
}}
>
确定删除
</button>
<button
className="flex-1 px-2 py-1.5 rounded-md text-xs font-medium bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors"
onClick={() => setDeleteLinkConfirm(null)}
>
取消
</button>
</div>
</div>
)}
{/* 居中设备详情小卡片 */}
<style>{`
@keyframes modalFadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes modalPop { from { opacity: 0; transform: scale(0.92) translateY(8px) } to { opacity: 1; transform: scale(1) translateY(0) } }
.detail-modal-bg { animation: modalFadeIn .18s ease-out }
.detail-modal-card { animation: modalPop .22s cubic-bezier(.2,.8,.2,1) }
.detail-scroll::-webkit-scrollbar { width: 4px }
.detail-scroll::-webkit-scrollbar-thumb { background: rgba(148,163,184,.25); border-radius: 4px }
`}</style>
{detailPanel.open && detailPanel.type === 'deviceDetail' && panelDeviceDetail && (() => {
const s = panelDeviceDetail.status;
const si = s === 'offline' ? { label: '离线', dot: '#64748b', bg: 'rgba(100,116,139,0.2)', fg: '#94a3b8', icon: '✕' }
: s === 'critical' ? { label: '严重', dot: '#FF4D4F', bg: 'rgba(239,68,68,0.15)', fg: '#f87171', icon: '●' }
: s === 'warning' ? { label: '警告', dot: '#FACC15', bg: 'rgba(245,158,11,0.15)', fg: '#fbbf24', icon: '!' }
: { label: '正常', dot: '#00FF66', bg: 'rgba(0,255,102,0.18)', fg: '#00FF66', icon: '✓' };
const cpu = panelDeviceDetail.cpu;
const cpuC = cpu != null && cpu > monitoring.thresholds.cpuCritical ? '#FF4D4F' : cpu != null && cpu > monitoring.thresholds.cpuWarning ? '#FACC15' : '#00FF66';
const mem = panelDeviceDetail.memory;
const memC = mem != null && mem > monitoring.thresholds.memCritical ? '#FF4D4F' : mem != null && mem > monitoring.thresholds.memWarning ? '#FACC15' : '#00FF66';
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center detail-modal-bg"
style={{ background: 'rgba(2,6,23,0.42)' }}
onClick={closeDetailPanel}>
<div className="detail-modal-card rounded-2xl w-[440px] max-h-[82vh] overflow-hidden"
style={{
background: 'linear-gradient(165deg, rgba(22,65,114,0.88) 0%, rgba(30,91,150,0.82) 100%)',
border: '1px solid rgba(90,176,255,0.45)',
backdropFilter: 'blur(20px) saturate(1.15)',
boxShadow: '0 20px 50px rgba(22,65,114,0.5), 0 0 30px rgba(90,176,255,0.25), inset 0 1px 0 rgba(125,211,252,0.15)',
}}
onClick={(e) => e.stopPropagation()}>
<div className="h-0.5 w-full" style={{ background: `linear-gradient(90deg, transparent 10%, ${si.dot} 40%, ${si.dot} 60%, transparent 90%)`, opacity: 0.7 }} />
<div className="px-5 pt-4 pb-3 flex items-center gap-3">
<div className="relative w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: `linear-gradient(135deg, ${si.bg}, rgba(14,165,233,0.08))`, border: `1px solid rgba(125,211,252,0.18)` }}>
<svg className="w-5 h-5 text-cyan-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="2" y="3" width="20" height="14" rx="2.5" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full flex items-center justify-center text-[8px] font-bold text-white"
style={{ background: si.dot, boxShadow: `0 0 8px ${si.dot}` }}>{si.icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[15px] font-semibold text-slate-100 truncate tracking-tight">{panelDeviceDetail.name}</span>
<span className="px-2 py-0.5 rounded-full text-[12px] font-semibold flex-shrink-0"
style={{ background: si.bg, color: si.fg, border: `1px solid ${si.fg}40` }}>{si.label}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400 mt-1">
<span className="px-1.5 py-0.5 rounded bg-cyan-950/40 border border-cyan-500/10">{panelDeviceDetail.type}</span>
<span className="font-mono">{panelDeviceDetail.ip || '—'}</span>
</div>
</div>
<button className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-slate-100 hover:bg-cyan-500/10 transition-all flex-shrink-0"
onClick={closeDetailPanel}>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-cyan-500/25 to-transparent" />
<div className="px-5 py-4 grid grid-cols-2 gap-3">
{[{ l: 'CPU', v: cpu, c: cpuC, i: '🖥' }, { l: '内存', v: mem, c: memC, i: '💾' }].map((m) => (
<div key={m.l} className="rounded-lg p-3"
style={{ background: 'rgba(20,40,70,0.28)', border: '1px solid rgba(125,211,252,0.14)' }}>
<div className="flex items-center gap-1.5 mb-2"><span className="text-xs">{m.i}</span>
<span className="text-xs text-slate-400 font-medium">{m.l}</span></div>
<div className="flex items-end justify-between mb-1.5">
<span className="text-lg font-bold text-slate-100 tabular-nums"
style={{ color: m.v != null ? m.c : undefined }}>{m.v ?? '—'}</span>
<span className="text-[12px] text-slate-500">%</span></div>
<div className="h-1.5 rounded-full bg-slate-700/40 overflow-hidden">
<div className="h-full rounded-full transition-all duration-500"
style={{ width: `${m.v ?? 0}%`, background: `linear-gradient(90deg, ${m.c}cc, ${m.c})`,
boxShadow: m.v != null ? `0 0 6px ${m.c}60` : undefined }} /></div></div>
))}
</div>
{panelDeviceDetail.alerts && panelDeviceDetail.alerts.length > 0 && (
<div className="px-5 pb-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-3 rounded-full bg-gradient-to-b from-amber-400 to-red-400" />
<span className="text-xs font-semibold text-slate-300 tracking-wide">最近告警</span>
<span className="text-[12px] text-slate-400 bg-cyan-950/40 border border-cyan-500/10 px-1.5 py-0.5 rounded">{panelDeviceDetail.alerts.length}</span>
</div>
<div className="detail-scroll space-y-1.5 max-h-[140px] overflow-y-auto pr-1">
{panelDeviceDetail.alerts.slice(0, 5).map((a) => {
const dot = a.level === 'critical' ? '#FF4D4F' : a.level === 'warning' ? '#FACC15' : '#06b6d4';
return (
<div key={a.id} className="flex items-start gap-2 text-xs py-2 px-2.5 rounded-lg"
style={{ background: 'rgba(20,40,70,0.22)', border: '1px solid rgba(125,211,252,0.08)' }}>
<span className="w-1.5 h-1.5 rounded-full mt-2 flex-shrink-0"
style={{ background: dot, boxShadow: `0 0 6px ${dot}` }} />
<div className="flex-1 min-w-0">
<div className="text-slate-300 truncate text-[12px] leading-relaxed">{a.message}</div>
<div className="text-slate-500 text-[12px] mt-0.5">{a.time}</div>
</div>
</div>);
})}
</div>
</div>
)}
<div className="px-5 py-3 flex items-center justify-between" style={{ borderTop: '1px solid rgba(125,211,252,0.14)' }}>
<div className="flex items-center gap-1.5 text-[12px] text-slate-500">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>
更新:{(() => { const t = panelDeviceDetail.lastUpdate; if (!t) return '—'; const d = Date.now() - t; if (d < 60000) return '刚刚'; if (d < 3600000) return `${Math.floor(d / 60000)} 分钟前`; return `${Math.floor(d / 3600000)} 小时前`; })()}
</div>
<button className="px-3.5 py-1.5 rounded-lg text-xs font-medium"
style={{ background: 'rgba(6,182,212,0.12)', color: '#67e8f9', border: '1px solid rgba(6,182,212,0.22)' }}
onClick={closeDetailPanel}>关闭</button>
</div>
</div>
</div>
);
})()}
{detailPanel.type !== 'deviceDetail' && (
<MonitoringDetailPanel
open={detailPanel.open}
onClose={closeDetailPanel}
panelType={detailPanel.type}
panelTitle={detailPanel.title}
deviceList={panelDeviceList}
alertList={panelAlertList}
deviceDetail={panelDeviceDetail}
taskList={panelTaskList}
onDeviceClick={(deviceId) => openDeviceDetail(deviceId)}
thresholds={monitoring.thresholds}
/>
)}
</div>
</>
);
}
export default MonitoringTopologyView;