/** * Workbench (工作台) —— NetMesh 概览 Dashboard v2 */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as echarts from "echarts"; import { Server, KeyRound, GitBranch, FileCode, Network, AlertTriangle, Activity, Zap, Loader2, Terminal, ArrowRight, ChevronRight, } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { useConnectionLogsStore } from "../../application/state/connectionLogsStore"; import { inspectionDeviceApi, inspectionCatalogApi, inspectionResultApi, inspectionAlertApi, inspectionReportApi, inspectionDailyReportApi, type DeviceExtension, type DeviceCatalog, type DailyReport, type AlertRecord, type ReportSnapshot, } from "../../infrastructure/inspection/inspectionApi"; /* =================== 类型 =================== */ interface WorkbenchHost { id?: string; label?: string; hostname?: string; tags?: string[]; group?: string; deviceType?: string; } interface WorkbenchKey { id?: string; label?: string; } interface WorkbenchProxy { id?: string; label?: string; } interface WorkbenchSectionProps { hosts: WorkbenchHost[]; keys: WorkbenchKey[]; proxies: WorkbenchProxy[]; snippetCount?: number; activePortForwardCount?: number; onNavigate: (section: string, opts?: { alertId?: string; logId?: string; hostId?: string }) => void; onConnectHost?: (hostId: string) => void; } interface InspectResult { deviceId?: string; hostId?: string; device_id?: string; metrics?: { cpu?: number; memory?: number; online?: boolean }; timestamp?: number; collectedAt?: number | string; status?: string; online?: boolean; } function getInspectionApi(): any { try { return (window as any).netcatty?.inspection ?? null; } catch { return null; } } /* =================== 设备类别定义(单一数据源) =================== */ const DEVICE_CATEGORY_META: Record = { server: { label: "服务器", color: "#3b82f6" }, switch: { label: "交换机", color: "#10b981" }, router: { label: "路由器", color: "#f59e0b" }, firewall: { label: "防火墙", color: "#ef4444" }, storage: { label: "存储设备", color: "#8b5cf6" }, workstation:{ label: "工作站", color: "#06b6d4" }, other: { label: "未分类", color: "#64748b" }, }; const DEVICE_CATEGORY_KEYS = Object.keys(DEVICE_CATEGORY_META); function deviceTypeLabel(t?: string): string { const meta = DEVICE_CATEGORY_META[(t || "").toLowerCase()]; return meta ? meta.label : "未分类"; } function deviceTypeColor(key: string): string { return DEVICE_CATEGORY_META[key]?.color ?? "#64748b"; } const CATALOG_TYPE_TO_DISPLAY: Record = { linux: "server", windows: "server", huawei_h3c_switch: "switch", cisco_switch: "switch", huawei_firewall: "firewall", fortinet_firewall: "firewall", }; function mapCatalogTypeKeyToCategory(tk: string, name: string, vendor: string): string { const key = tk.toLowerCase(); if (CATALOG_TYPE_TO_DISPLAY[key]) return CATALOG_TYPE_TO_DISPLAY[key]; if (DEVICE_CATEGORY_KEYS.includes(key)) return key; const blob = `${tk} ${name} ${vendor}`.toLowerCase(); if (/h3c|huawei|vrp|comware|cisco|switch|交换机/.test(blob)) return "switch"; if (/router|路由/.test(blob)) return "router"; if (/firewall|防火墙|\bfw\b/.test(blob)) return "firewall"; if (/storage|存储|nas|san/.test(blob)) return "storage"; if (/workstation|\bpc\b|桌面/.test(blob)) return "workstation"; if (/linux|windows|server|服务器/.test(blob)) return "server"; return "other"; } function classifyHostType(host: WorkbenchHost, ext?: DeviceExtension, catalogs?: DeviceCatalog[]): string { if (ext?.catalog_id && catalogs && catalogs.length > 0) { const cat = catalogs.find((c) => c.id === ext!.catalog_id); if (cat) return mapCatalogTypeKeyToCategory(cat.type_key || "", cat.name || "", cat.vendor || ""); } const directType = (host as any).type; if (directType && DEVICE_CATEGORY_KEYS.includes(directType)) return directType; const distro = ((host as any).distro || (host as any).catalog || (host as any).catalog_id || "").toLowerCase(); if (distro) return mapCatalogTypeKeyToCategory(distro, "", ""); const label = (host.label || host.hostname || "").toLowerCase(); if (/h3c|huawei|vrp|comware|cisco|switch|交换机/.test(label)) return "switch"; if (/router|路由/.test(label)) return "router"; if (/firewall|防火墙/.test(label)) return "firewall"; if (/storage|存储|nas|san/.test(label)) return "storage"; if (/workstation|\bpc\b|桌面/.test(label)) return "workstation"; if (/linux|windows|server|服务器/.test(label)) return "server"; return "other"; } function formatTimeAgo(input: number | string | undefined): string { if (!input) return "-"; const t = typeof input === "number" ? input : new Date(input).getTime(); const diff = Date.now() - t; if (diff < 60_000) return "刚刚"; if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`; if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)} 小时前`; return `${Math.floor(diff / 86_400_000)} 天前`; } /* =================== KPI 卡 =================== */ function KpiCard({ icon: Icon, label, value, sub, accent = "bg-primary/10 text-primary" }: { icon: React.ElementType; label: string; value: React.ReactNode; sub?: string; accent?: string; }) { return (
{value}
{sub && {sub}}
{label}
); } function SectionTitle({ title, action }: { title: string; action?: React.ReactNode }) { return (

{title}

{action}
); } /* =================== 通用 ECharts hook =================== */ function useChart(optionFn: () => echarts.EChartsOption | null, deps: React.DependencyList) { const ref = useRef(null); const chartRef = useRef(null); useEffect(() => { if (!ref.current) return; if (!chartRef.current) { chartRef.current = echarts.init(ref.current); const onResize = () => chartRef.current?.resize(); window.addEventListener("resize", onResize); return () => { window.removeEventListener("resize", onResize); chartRef.current?.dispose(); chartRef.current = null; }; } }, []); useEffect(() => { if (!chartRef.current) return; const opt = optionFn(); if (opt) chartRef.current.setOption(opt, true); else chartRef.current.clear(); // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return ref; } /* =================== 近一周正常/离线堆叠柱状图 =================== */ function WeeklyOnlineOfflineBarChart({ results }: { results: InspectResult[] }) { const ref = useChart(() => { const days: string[] = [], online: number[] = [], offline: number[] = []; const now = new Date(); for (let i = 6; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); days.push(`${d.getMonth() + 1}/${d.getDate()}`); const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const end = start + 86_400_000; const latestByDevice = new Map(); for (const r of results) { const t = typeof (r as any).timestamp === "number" ? (r as any).timestamp : new Date((r as any).collectedAt || 0).getTime(); if (t < start || t >= end) continue; const did = (r as any).device_id || r.deviceId || r.hostId; if (!did) continue; const exist = latestByDevice.get(did); const eTs = exist ? ((exist as any).timestamp || 0) : 0; if (!exist || t > eTs) latestByDevice.set(did, r); } let on = 0, off = 0; for (const r of latestByDevice.values()) { if (r.online === false || r.metrics?.online === false) off++; else on++; } online.push(on); offline.push(off); } const totalOn = online.reduce((a, b) => a + b, 0), totalOff = offline.reduce((a, b) => a + b, 0); return { grid: { left: 32, right: 12, top: 28, bottom: 28 }, tooltip: { trigger: "axis", axisPointer: { type: "shadow" } }, legend: { top: 4, right: 12, textStyle: { fontSize: 11, color: "var(--muted-foreground)" }, itemWidth: 10, itemHeight: 6 }, title: { text: `${totalOn + totalOff} 台次 · ${totalOn} 在线 / ${totalOff} 离线`, textStyle: { fontSize: 11, color: "var(--muted-foreground)", fontWeight: 400 }, left: 12, top: 4 }, xAxis: { type: "category", data: days, axisLabel: { fontSize: 10, color: "var(--muted-foreground)" }, axisLine: { lineStyle: { color: "var(--border)" } }, axisTick: { show: false } }, yAxis: { type: "value", splitLine: { lineStyle: { color: "var(--border)", type: "dashed" } }, axisLabel: { fontSize: 10, color: "var(--muted-foreground)" } }, series: [ { name: "在线", type: "bar", stack: "status", barWidth: "48%", emphasis: { focus: "series" }, data: online, itemStyle: { borderRadius: [0, 0, 0, 0], color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#10b981" }, { offset: 1, color: "#059669" }] } } }, { name: "离线", type: "bar", stack: "status", emphasis: { focus: "series" }, data: offline, itemStyle: { borderRadius: [4, 4, 0, 0], color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#ef4444" }, { offset: 1, color: "#b91c1c" }] } } }, ], } as echarts.EChartsOption; }, [results]); return
; } /* =================== 设备类型分布圆环图 =================== */ function DeviceTypeDonutChart({ hosts, extensions, catalogs }: { hosts: WorkbenchHost[]; extensions?: Record; catalogs?: DeviceCatalog[] }) { const ref = useChart(() => { // 从 DEVICE_CATEGORY_META 动态构建 counts,彻底消除硬编码 const counts: Record = {}; for (const key of DEVICE_CATEGORY_KEYS) counts[key] = 0; for (const h of hosts) { const ext = extensions && h.id ? extensions[h.id] : undefined; const cat = classifyHostType(h, ext, catalogs); if (counts[cat] !== undefined) counts[cat]++; else counts["other"]++; // 兜底:未知类别归入 other } const data = DEVICE_CATEGORY_KEYS .map((k) => ({ name: deviceTypeLabel(k), value: counts[k], color: deviceTypeColor(k) })) .filter((d) => d.value > 0); const total = data.reduce((s, d) => s + d.value, 0); if (total === 0) return null; return { tooltip: { trigger: "item", formatter: "{b}: {c} 台 ({d}%)" }, title: { text: `${total}`, subtext: "台设备", left: "34%", top: "center", textStyle: { fontSize: 20, fontWeight: 700, color: "var(--foreground)" }, subtextStyle: { fontSize: 10, color: "var(--muted-foreground)" } }, series: [{ type: "pie", radius: ["48%", "72%"], center: ["34%", "50%"], avoidLabelOverlap: true, itemStyle: { borderRadius: 4, borderColor: "var(--card)", borderWidth: 2 }, label: { show: false }, labelLine: { show: false }, data: data.map((d) => ({ name: d.name, value: d.value, itemStyle: { color: d.color } })) }], // graphic 直接用 data[i].color,不再靠 label 反查 index graphic: data.map((d, i) => ({ type: "text", left: "68%", top: `${18 + i * 16}%`, style: { text: `{dot|} {lbl|${d.name}} {val|${d.value}} {pct|${((d.value / total) * 100).toFixed(1)}%}`, rich: { dot: { width: 8, height: 8, backgroundColor: d.color, borderRadius: 4, padding: [0, 6, 0, 0] }, lbl: { color: "var(--foreground)", fontSize: 11 }, val: { color: "var(--foreground)", fontSize: 12, fontWeight: 600, padding: [0, 5, 0, 0] }, pct: { color: "var(--muted-foreground)", fontSize: 11 }, }, }, })), } as echarts.EChartsOption; }, [hosts, extensions, catalogs]); return
; } /* =================== 滚动行组件 =================== */ function AlertScrollRow({ alert, onClick }: { alert: AlertRecord; onClick?: () => void }) { const levelStyles: Record = { critical: "bg-red-500/15 text-red-500", warning: "bg-amber-500/15 text-amber-500", info: "bg-sky-500/15 text-sky-500" }; const levelLabel: Record = { critical: "严重", warning: "警告", info: "提示" }; return ( ); } function ReportScrollRow({ report, onClick }: { report: ReportSnapshot; onClick?: () => void }) { const dateLabel = new Date(report.timestamp).toLocaleDateString(); return ( ); } function DailyReportScrollRow({ report, onClick }: { report: DailyReport; onClick?: () => void }) { const modeLabel = report.mode === "finalized" ? "已发布" : "待确认"; const statusColor = report.mode === "finalized" ? "bg-emerald-500/15 text-emerald-500" : "bg-amber-500/15 text-amber-500"; return ( ); } function QuickConnectRow({ host, onConnect }: { host: WorkbenchHost; onConnect?: (host: WorkbenchHost) => void }) { const label = host.label || host.hostname || "未命名"; return ( ); } function RecentSessionRow({ log, hosts, onClick }: { log: any; hosts: WorkbenchHost[]; onClick?: () => void }) { const host = hosts.find((h) => h.id === log.hostId); const label = host?.label || log.hostLabel || log.hostname || "未知"; const isError = (log.exitCode ?? null) !== null && (log.exitCode ?? null) !== undefined; return ( ); } /* =================== 主组件 =================== */ export function WorkbenchView({ hosts, keys, proxies, snippetCount = 0, activePortForwardCount = 0, onNavigate, onConnectHost }: WorkbenchSectionProps) { const inspectionApi = getInspectionApi(); const [results, setResults] = useState([]); const [alerts, setAlerts] = useState([]); const [reports, setReports] = useState([]); const [dailyReports, setDailyReports] = useState([]); const [extensions, setExtensions] = useState>({}); const [catalogs, setCatalogs] = useState([]); const [loading, setLoading] = useState(true); const [lastUpdate, setLastUpdate] = useState(0); const { connectionLogs } = useConnectionLogsStore(); const fetchData = useCallback(async () => { setLoading(true); try { let historyResults: InspectResult[] = []; let latestResults: InspectResult[] = []; // 优先用按时间范围查(过去 8 天完整历史),避免 getRecent(N) 被今天新结果挤出昨天数据 try { const now = Date.now(); const eightDaysAgo = now - 9 * 86_400_000; // 多一天保险 historyResults = ((await inspectionResultApi.getByTimeRange(eightDaysAgo, now)) as any) || []; } catch {} if (historyResults.length === 0) { try { historyResults = ((await inspectionResultApi.getRecent(1000)) as any) || []; } catch {} } try { latestResults = (await inspectionApi?.getLatestAll?.()) || []; } catch {} setResults(historyResults.length > 0 ? historyResults : latestResults); // 用 inspectionAlertApi(AlertRecord 类型,snake_case 字段) let alertList: AlertRecord[] = []; try { alertList = await inspectionAlertApi.getActive(); } catch {} if (alertList.length === 0) { try { alertList = (await inspectionApi?.listAlerts?.()) || (await inspectionApi?.getActiveAlerts?.()) || []; } catch {} } setAlerts(alertList); try { setReports(await inspectionReportApi.listSnapshots(15)); } catch {} try { const daily = await inspectionDailyReportApi.list(15); setDailyReports(Array.isArray(daily) ? daily : []); } catch {} try { const [exts, cats] = await Promise.all([ inspectionDeviceApi.getAll().catch(() => ({})), inspectionCatalogApi.list().catch(() => []), ]); if (exts && typeof exts === "object") setExtensions(exts as Record); if (Array.isArray(cats)) setCatalogs(cats); } catch {} setLastUpdate(Date.now()); } catch (e: any) { console.warn("[Workbench] fetch failed:", e?.message || e); } finally { setLoading(false); } }, [inspectionApi]); useEffect(() => { void fetchData(); const t = setInterval(() => void fetchData(), 60_000); return () => clearInterval(t); }, [fetchData]); const stats = useMemo(() => { const latestByDevice = new Map(); for (const r of results) { const id = (r as any).device_id || r.deviceId || r.hostId; if (!id) continue; const exist = latestByDevice.get(id); const rTs = (r as any).timestamp || 0; const eTs = exist ? ((exist as any).timestamp || 0) : 0; if (!exist || rTs > eTs) latestByDevice.set(id, r); } let online = 0, offline = 0; for (const r of latestByDevice.values()) { if (r.online === false || r.metrics?.online === false) offline++; else online++; } return { online, offline }; }, [results]); const recentLogs = useMemo(() => [...(connectionLogs || [])].sort((a: any, b: any) => (b.startTime || b.startedAt || 0) - (a.startTime || a.startedAt || 0)).slice(0, 15), [connectionLogs]); const quickConnectHosts = useMemo(() => hosts, [hosts]); const handleQuickConnect = useCallback((host: WorkbenchHost) => { // 优先开终端 → 没有 hostId 时 fallback 到跳 hosts section 选中 if (onConnectHost && host.id) { onConnectHost(host.id); return; } if (host.id) onNavigate("hosts", { hostId: host.id }); else onNavigate("hosts"); }, [onConnectHost, onNavigate]); const alertList = alerts.slice(0, 30), reportList = reports.slice(0, 20), dailyReportList = dailyReports.slice(0, 20); const navigate = useCallback((section: string, opts?: any) => onNavigate(section, opts), [onNavigate]); // === 复用已有页面的预览/详情 === const handlePreviewSnapshot = useCallback(async (snap: ReportSnapshot) => { try { await inspectionReportApi.previewSnapshotPdf(snap.id); } catch { navigate("reports"); } }, [navigate]); const handlePreviewDaily = useCallback(async (r: DailyReport) => { try { await inspectionDailyReportApi.previewPdf(r.id); } catch { navigate("reports"); } }, [navigate]); return (
{/* 顶部标题条 */}

工作台

{loading ? "加载中..." : lastUpdate ? `最后更新 ${formatTimeAgo(lastUpdate)}` : "暂无巡检数据"}

{/* ============ 8 KPI 卡 ============ */}
{/* ============ 主体:外层 3 列 grid,左 2 列 + 右 1 列各自独立 ============ */}
{/* ===== 左侧 2 列:flex-col 独立容器 ===== */}
{/* 左侧 Row A:快速连接 + 最近会话 */}
navigate("hosts")} className="h-7 text-xs">全部 } /> {quickConnectHosts.length === 0 ?
还没有主机
:
{quickConnectHosts.map((h) => )}
}
navigate("logs")} className="h-7 text-xs">全部 } /> {recentLogs.length === 0 ?
暂无记录
:
{recentLogs.map((log) => navigate("logs", { logId: log.id || log.sessionId })} />)}
}
{/* 左侧 Row B:巡检快照 + 巡检日报 */}
navigate("reports")} className="h-7 text-xs">全部 } /> {reportList.length === 0 ?
暂无快照
:
{reportList.map((r) => handlePreviewSnapshot(r)} />)}
}
navigate("reports")} className="h-7 text-xs">全部 } /> {dailyReportList.length === 0 ?
暂无日报
:
{dailyReportList.map((r) => handlePreviewDaily(r)} />)}
}
{/* 左侧 Row C:快捷操作 */}
{[ { icon: KeyRound, label: "密钥管理", sub: "管理 SSH 密钥", section: "keys", accent: "from-amber-500/20 to-amber-500/5 text-amber-600 border-amber-500/30 hover:from-amber-500/30 hover:to-amber-500/10" }, { icon: Network, label: "端口转发", sub: "管理端口映射", section: "port", accent: "from-blue-500/20 to-blue-500/5 text-blue-600 border-blue-500/30 hover:from-blue-500/30 hover:to-blue-500/10" }, { icon: FileCode, label: "代码片段", sub: "常用代码块", section: "snippets", accent: "from-violet-500/20 to-violet-500/5 text-violet-600 border-violet-500/30 hover:from-violet-500/30 hover:to-violet-500/10" }, { icon: Zap, label: "告警规则", sub: "配置告警策略", section: "alertRules", accent: "from-rose-500/20 to-rose-500/5 text-rose-600 border-rose-500/30 hover:from-rose-500/30 hover:to-rose-500/10" }, { icon: Server, label: "监控大屏", sub: "拓扑可视化", section: "monitoring", accent: "from-emerald-500/20 to-emerald-500/5 text-emerald-600 border-emerald-500/30 hover:from-emerald-500/30 hover:to-emerald-500/10" }, ].map(({ icon: Icon, label, sub, section, accent }) => ( ))}
{/* ===== 右侧 1 列:flex-col 独立容器 ===== */}
{/* 右侧 A:设备类型分布 — 自然矮 */}
{hosts.length === 0 ?
暂无主机数据
:
}
{/* 右侧 B:近一周巡检状态 — 自然矮 */}
navigate("inspection")} className="h-7 text-xs">巡检中心 } /> {results.length === 0 ?
暂无巡检数据
:
}
{/* 右侧 C:活跃告警 — flex-1 填满剩余空间 */}
navigate("alerts")} className="h-7 text-xs">全部 } /> {alertList.length === 0 ?
全部正常
:
{alertList.map((a) => navigate("alerts", { alertId: a.id })} />)}
}
); } export default WorkbenchView;