Files
SenMeshNet/components/vault/WorkbenchView.tsx
zhaolei 4a02370006
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
feat: add monitoring settings tab, workbench view, and inspection alert improvements
2026-10-30 12:12:04 +08:00

527 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, { label: string; color: string }> = {
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<string, string> = {
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 (
<div className="group relative overflow-hidden rounded-xl border border-border/50 bg-gradient-to-br from-card to-card/80 p-3 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-md">
<div className="flex items-start gap-2.5">
<div className={cn("flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg shadow-sm ring-1 ring-inset ring-current/10 transition-transform group-hover:scale-110 group-hover:rotate-3", accent)}>
<Icon size={15} strokeWidth={2.2} />
</div>
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-baseline gap-1.5">
<div className="text-xl font-bold tabular-nums leading-none tracking-tight text-foreground">{value}</div>
{sub && <span className="text-[10px] font-medium text-muted-foreground">{sub}</span>}
</div>
<div className="mt-1 text-[11px] font-medium tracking-wide text-muted-foreground/75">{label}</div>
</div>
</div>
</div>
);
}
function SectionTitle({ title, action }: { title: string; action?: React.ReactNode }) {
return (
<div className="mb-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-primary/70" />
<h3 className="text-sm font-semibold tracking-wide text-foreground/90">{title}</h3>
</div>
{action}
</div>
);
}
/* =================== 通用 ECharts hook =================== */
function useChart(optionFn: () => echarts.EChartsOption | null, deps: React.DependencyList) {
const ref = useRef<HTMLDivElement>(null);
const chartRef = useRef<echarts.ECharts | null>(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<string, InspectResult>();
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 <div ref={ref} className="h-[170px] w-full" />;
}
/* =================== 设备类型分布圆环图 =================== */
function DeviceTypeDonutChart({ hosts, extensions, catalogs }:
{ hosts: WorkbenchHost[]; extensions?: Record<string, DeviceExtension>; catalogs?: DeviceCatalog[] }) {
const ref = useChart(() => {
// 从 DEVICE_CATEGORY_META 动态构建 counts,彻底消除硬编码
const counts: Record<string, number> = {};
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 <div ref={ref} className="h-[170px] w-full" />;
}
/* =================== 滚动行组件 =================== */
function AlertScrollRow({ alert, onClick }: { alert: AlertRecord; onClick?: () => void }) {
const levelStyles: Record<string, string> = { 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<string, string> = { critical: "严重", warning: "警告", info: "提示" };
return (
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[10px] font-semibold", levelStyles[alert.level] || levelStyles.warning)}>
{(levelLabel[alert.level] || "警告")[0]}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground/90">{alert.message || "未知告警"}</div>
<div className="truncate text-[10px] text-muted-foreground">{alert.device_name || alert.device_id || "-"} · {alert.metric || "—"} = {alert.metric_value ?? "?"}</div>
</div>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(alert.triggered_at)}</span>
</button>
);
}
function ReportScrollRow({ report, onClick }: { report: ReportSnapshot; onClick?: () => void }) {
const dateLabel = new Date(report.timestamp).toLocaleDateString();
return (
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-indigo-500/15 text-indigo-500"><Activity size={10} /></div>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground/90">巡检快照 · {dateLabel}</div>
<div className="truncate text-[10px] text-muted-foreground">{report.devices?.length ?? 0} 台设备 · {(report.duration_ms / 1000).toFixed(1)}s · {report.trigger}</div>
</div>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(report.timestamp)}</span>
</button>
);
}
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 (
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-sky-500/15 text-sky-500"><Activity size={10} /></div>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground/90">日巡检报告 {report.date || "-"}
<span className={cn("ml-1.5 rounded px-1 py-px text-[9px] font-medium", statusColor)}>{modeLabel}</span>
</div>
<div className="truncate text-[10px] text-muted-foreground">{report.summary ? `${report.summary.deviceCount ?? 0} 台 · ${(report.summary.failRate * 100).toFixed(1)}% 失败` : ""}</div>
</div>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(report.timestamp)}</span>
</button>
);
}
function QuickConnectRow({ host, onConnect }: { host: WorkbenchHost; onConnect?: (host: WorkbenchHost) => void }) {
const label = host.label || host.hostname || "未命名";
return (
<button type="button" onClick={() => onConnect?.(host)}
className="group flex w-full items-center gap-2 rounded-lg border border-border/50 bg-card/60 px-2.5 py-2 text-left text-xs transition-colors hover:border-primary/30 hover:bg-primary/5">
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-primary/10 text-primary"><Terminal size={11} /></div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground/90">{label}</div>
<div className="truncate text-[10px] text-muted-foreground">{host.hostname || host.group || "—"}</div>
</div>
<ChevronRight size={10} className="flex-shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
);
}
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 (
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/40">
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded", isError ? "bg-red-500/10 text-red-500" : "bg-emerald-500/10 text-emerald-500")}>
<Terminal size={10} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground/90">{label}</div>
<div className="truncate text-[10px] text-muted-foreground">{log.username ? `${log.username}@` : ""}{log.hostname || ""}</div>
</div>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(log.startTime || log.startedAt)}</span>
</button>
);
}
/* =================== 主组件 =================== */
export function WorkbenchView({ hosts, keys, proxies, snippetCount = 0, activePortForwardCount = 0, onNavigate, onConnectHost }: WorkbenchSectionProps) {
const inspectionApi = getInspectionApi();
const [results, setResults] = useState<InspectResult[]>([]);
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
const [reports, setReports] = useState<ReportSnapshot[]>([]);
const [dailyReports, setDailyReports] = useState<DailyReport[]>([]);
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState<number>(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<string, DeviceExtension>);
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<string, InspectResult>();
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 (
<div className="flex h-full min-h-0 flex-col gap-3.5 p-5">
{/* 顶部标题条 */}
<div className="flex flex-shrink-0 items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight text-foreground">工作台</h1>
<p className="mt-1 text-[11px] text-muted-foreground/80">
{loading ? "加载中..." : lastUpdate ? `最后更新 ${formatTimeAgo(lastUpdate)}` : "暂无巡检数据"}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void fetchData()} disabled={loading} className="h-8 border-border/60 bg-background/60 text-xs font-medium hover:bg-background">
{loading ? <Loader2 size={12} className="mr-1 animate-spin" /> : <Zap size={12} className="mr-1" />}刷新
</Button>
</div>
{/* ============ 8 KPI 卡 ============ */}
<div className="grid flex-shrink-0 grid-cols-2 gap-2.5 sm:grid-cols-4 lg:grid-cols-8">
<KpiCard icon={Server} label="主机总数" value={hosts.length} accent="bg-sky-500/10 text-sky-500" />
<KpiCard icon={KeyRound} label="密钥" value={keys.length} accent="bg-emerald-500/10 text-emerald-500" />
<KpiCard icon={GitBranch} label="代理" value={proxies.length} accent="bg-violet-500/10 text-violet-500" />
<KpiCard icon={FileCode} label="代码片段" value={snippetCount} accent="bg-amber-500/10 text-amber-500" />
<KpiCard icon={Network} label="端口转发" value={activePortForwardCount} accent="bg-cyan-500/10 text-cyan-500" />
<KpiCard icon={AlertTriangle} label="活跃告警" value={alerts.length}
accent={alerts.length ? "bg-red-500/10 text-red-500" : "bg-emerald-500/10 text-emerald-500"}
sub={alerts.length ? "需关注" : "全部正常"} />
<KpiCard icon={Activity} label="已巡检" value={results.length} accent="bg-blue-500/10 text-blue-500" />
<KpiCard icon={Server} label="在线主机" value={stats.online}
sub={hosts.length ? `${((stats.online / hosts.length) * 100).toFixed(0)}%` : "—"} accent="bg-green-500/10 text-green-500" />
</div>
{/* ============ 主体:外层 3 列 grid,左 2 列 + 右 1 列各自独立 ============ */}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 lg:grid-cols-3">
{/* ===== 左侧 2 列:flex-col 独立容器 ===== */}
<div className="flex min-h-0 flex-col gap-3 lg:col-span-2">
{/* 左侧 Row A:快速连接 + 最近会话 */}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="快速连接" action={<Button variant="ghost" size="sm" onClick={() => navigate("hosts")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{quickConnectHosts.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">还没有主机</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-1.5 pr-1">{quickConnectHosts.map((h) => <QuickConnectRow key={h.id} host={h} onConnect={handleQuickConnect} />)}</div></ScrollArea>}
</div>
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="最近会话" action={<Button variant="ghost" size="sm" onClick={() => navigate("logs")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{recentLogs.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无记录</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{recentLogs.map((log) => <RecentSessionRow key={log.id || log.sessionId} log={log} hosts={hosts} onClick={() => navigate("logs", { logId: log.id || log.sessionId })} />)}</div></ScrollArea>}
</div>
</div>
{/* 左侧 Row B:巡检快照 + 巡检日报 */}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="巡检快照" action={<Button variant="ghost" size="sm" onClick={() => navigate("reports")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{reportList.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无快照</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{reportList.map((r) => <ReportScrollRow key={r.id} report={r} onClick={() => handlePreviewSnapshot(r)} />)}</div></ScrollArea>}
</div>
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="巡检日报" action={<Button variant="ghost" size="sm" onClick={() => navigate("reports")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{dailyReportList.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无日报</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{dailyReportList.map((r) => <DailyReportScrollRow key={r.id} report={r} onClick={() => handlePreviewDaily(r)} />)}</div></ScrollArea>}
</div>
</div>
{/* 左侧 Row C:快捷操作 */}
<div className="flex-shrink-0 rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="快捷操作" />
<div className="mt-3 grid grid-cols-5 gap-3">
{[
{ 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 }) => (
<button key={section} type="button" onClick={() => navigate(section)}
className={`group flex h-12 items-center gap-2.5 rounded-lg border bg-gradient-to-br px-3 text-left transition-all hover:-translate-y-0.5 hover:shadow-sm ${accent}`}>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-background/40 ring-1 ring-inset ring-current/15">
<Icon size={15} strokeWidth={2.2} className="transition-transform group-hover:scale-110" />
</span>
<div className="min-w-0 leading-tight">
<div className="text-[12px] font-semibold truncate">{label}</div>
<div className="text-[10px] text-muted-foreground/70 truncate">{sub}</div>
</div>
</button>
))}
</div>
</div>
</div>
{/* ===== 右侧 1 列:flex-col 独立容器 ===== */}
<div className="flex min-h-0 flex-col gap-3">
{/* 右侧 A:设备类型分布 — 自然矮 */}
<div className="flex flex-shrink-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="设备类型分布" />
{hosts.length === 0 ? <div className="flex items-center justify-center text-xs text-muted-foreground">暂无主机数据</div>
: <div className="flex items-center"><DeviceTypeDonutChart hosts={hosts} extensions={extensions} catalogs={catalogs} /></div>}
</div>
{/* 右侧 B:近一周巡检状态 — 自然矮 */}
<div className="flex flex-shrink-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="近一周巡检状态" action={<Button variant="ghost" size="sm" onClick={() => navigate("inspection")} className="h-7 text-xs">巡检中心 <ArrowRight size={10} className="ml-1" /></Button>} />
{results.length === 0 ? <div className="flex items-center justify-center text-xs text-muted-foreground">暂无巡检数据</div>
: <div className="flex items-end"><WeeklyOnlineOfflineBarChart results={results} /></div>}
</div>
{/* 右侧 C:活跃告警 — flex-1 填满剩余空间 */}
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="活跃告警" action={<Button variant="ghost" size="sm" onClick={() => navigate("alerts")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{alertList.length === 0 ? <div className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground"><Activity size={14} className="text-emerald-500/60" /> 全部正常</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{alertList.map((a) => <AlertScrollRow key={a.id} alert={a} onClick={() => navigate("alerts", { alertId: a.id })} />)}</div></ScrollArea>}
</div>
</div>
</div>
</div>
);
}
export default WorkbenchView;