feat: add monitoring settings tab, workbench view, and inspection alert improvements
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

This commit is contained in:
2026-10-30 12:12:04 +08:00
committed by trae-solo-agent
parent 16dd04f746
commit 4a02370006
26 changed files with 2569 additions and 233 deletions

View File

@@ -0,0 +1,526 @@
/**
* 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;