Files
SenMeshNet/components/inspection/ReportPage.tsx
zhaolei 2e71e03d71
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: 巡检增强 / 拓扑大屏 / 远程 HTTP / portable 绿色打包
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立
Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心
与告警中心页增强。

拓扑大屏:MonitoringTopologyView 扩展分组/布局控制,
useMonitoringData 重构数据流。

远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露
IPC,registerBridges 注册 netcatty:http:open 桥。

portable 绿色打包:
- electron-builder.config.cjs 恢复原始白名单(61MB app.asar),
  收集器自动追踪运行时依赖;
- portableData.cjs 重写为 .portable 标记检测;
- scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage
  的 http://localhost:5173 origin 批量迁移为 app://netcatty;
- scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
2026-10-19 21:11:57 +08:00

1375 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
Calendar,
ChevronDown,
ChevronRight,
Download,
FileText,
ListChecks,
Loader2,
Play,
RefreshCw,
Server,
Settings2,
Terminal,
Trash2,
Wifi,
WifiOff,
} from "lucide-react";
import { Button } from "../ui/button";
import { Card } from "../ui/card";
import { ScrollArea } from "../ui/scroll-area";
import { Switch } from "../ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { toast } from "../ui/toast";
import {
inspectionDailyReportApi,
inspectionReportApi,
inspectionReportScheduleApi,
inspectionSnapshotExportApi,
type DailyReport,
type ReportDeviceTarget,
type ReportSchedule,
type ReportSnapshot,
} from "../../infrastructure/inspection/inspectionApi";
import { summarizeSnapshot } from "../../infrastructure/inspection/reportSummary";
import {
STORAGE_KEY_HOSTS,
STORAGE_KEY_KEYS,
} from "../../infrastructure/config/storageKeys";
import {
decryptHosts,
decryptKeys,
} from "../../infrastructure/persistence/secureFieldAdapter";
// ─── 类型 ──────────────────────────────────────
// Host / SSHKey 是 secureFieldAdapter 的真实类型,这里用 any 兜底避免额外 import
type HostAny = any;
type KeyAny = any;
// ─── 工具 ──────────────────────────────────────
const CATALOG_LABEL: Record<string, string> = {
linux_server: "Linux 服务器",
huawei_h3c_switch: "华为/H3C 交换机",
cisco_switch: "Cisco IOS 交换机",
huawei_firewall: "华为防火墙",
fortinet_firewall: "飞塔防火墙",
unknown: "未知设备",
};
function formatTime(ts: number) {
const d = new Date(ts);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatDuration(ms: number) {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
/** 从 vault 解密 + device extension 拿到所有设备的 SSH target
* enabledDeviceIds 为空/undefined = 全部设备;有值则只返回列表内的
* 返回值第二个元素是完整 host 列表(用于 SchedulePanel 渲染设备多选)
*/
async function buildAllDeviceTargets(enabledDeviceIds?: string[]): Promise<{
targets: ReportDeviceTarget[];
allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }>;
}> {
const targets: ReportDeviceTarget[] = [];
const allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }> = [];
let hosts: HostAny[] = [];
try {
const raw = localStorage.getItem(STORAGE_KEY_HOSTS);
if (raw) hosts = await decryptHosts(JSON.parse(raw));
} catch (_) {}
let keys: KeyAny[] = [];
try {
const raw = localStorage.getItem(STORAGE_KEY_KEYS);
if (raw) keys = await decryptKeys(JSON.parse(raw));
} catch (_) {}
let extensions: Record<string, any> = {};
try {
const raw = localStorage.getItem("netcatty_inspection_device_extension_v1");
if (raw) extensions = JSON.parse(raw);
} catch (_) {}
for (const host of hosts) {
const ext = extensions[host.id];
const catalogId = ext?.catalog_id || undefined;
const label = host.label || host.hostname;
allHosts.push({ id: host.id, hostname: host.hostname, catalog_id: catalogId, label });
// 过滤:enabledDeviceIds 有值且当前不在列表里 → 跳过
if (enabledDeviceIds && enabledDeviceIds.length > 0 && !enabledDeviceIds.includes(host.id)) continue;
const key = host.identityFileId
? keys.find((k: KeyAny) => k.id === host.identityFileId)
: undefined;
targets.push({
device_id: host.id,
hostname: host.hostname,
port: host.port || 22,
username: host.username,
password: host.password,
privateKeyContent: key?.privateKeyContent,
passphrase: key?.passphrase,
catalog_id: catalogId,
});
}
return { targets, allHosts };
}
// ─── 主组件 ────────────────────────────────────
export function ReportPage() {
const [snapshots, setSnapshots] = useState<ReportSnapshot[]>([]);
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [expandedSnap, setExpandedSnap] = useState<string | null>(null);
const [expandedDevice, setExpandedDevice] = useState<string | null>(null);
// 日运行报告
const [dailyReports, setDailyReports] = useState<DailyReport[]>([]);
const [dailyLoading, setDailyLoading] = useState(false);
const [generating, setGenerating] = useState(false);
// 调度配置
const [schedule, setSchedule] = useState<ReportSchedule | null>(null);
const [scheduleOpen, setScheduleOpen] = useState(false);
// 所有 vault 设备(用于 SchedulePanel 渲染多选列表)
const [allHosts, setAllHosts] = useState<Array<{ id: string; hostname: string; catalog_id?: string; label: string }>>([]);
// 定时巡检跟踪:记录上次执行时间(手动 + 自动都更新)
const lastRunAtRef = useRef<number>(0);
// AI 汇总
const [aiSummaries, setAiSummaries] = useState<Record<string, any>>({});
const [analyzingIds, setAnalyzingIds] = useState<Set<string>>(new Set());
const [aiErrors, setAiErrors] = useState<Record<string, string>>({});
/** AI 状态计算:
* 优先从快照 JSON 读(持久化/主进程写入,页面切换不丢):
* ai_summary_status === "running" → analyzing
* ai_summary_status === "done" + ai_summary 存在 → done
* ai_summary_status === "failed" → failed
* 其次本地内存状态(本次页面内手动触发时立刻标记):
* analyzingIds.has → analyzing
* aiErrors 里有 → failed
* 否则 idle
*/
const getAiState = useCallback((snapId: string) => {
const fromSnap = snapshots.find(s => s.id === snapId);
const status = fromSnap?.ai_summary_status;
if (status === "running") return "analyzing" as const;
if (status === "done" && fromSnap.ai_summary) return "done" as const;
if (status === "failed") return "failed" as const;
// 本地内存状态(兜底)
if (analyzingIds.has(snapId)) return "analyzing" as const;
if (aiSummaries[snapId]) return "done" as const;
if (aiErrors[snapId]) return "failed" as const;
return "idle" as const;
}, [snapshots, analyzingIds, aiSummaries, aiErrors]);
/** 从快照或本地 state 取 AI 汇总内容 */
const getAiSummaryFor = useCallback((snapId: string): any | undefined => {
const fromSnap = snapshots.find(s => s.id === snapId)?.ai_summary;
if (fromSnap) return fromSnap;
return aiSummaries[snapId];
}, [snapshots, aiSummaries]);
const loadSnapshots = useCallback(async () => {
setLoading(true);
try {
const data = await inspectionReportApi.listSnapshots(50);
setSnapshots(data || []);
} catch (e) {
console.error("[ReportPage] listSnapshots failed:", e);
toast.error("加载快照失败");
} finally {
setLoading(false);
}
}, []);
const handleAiAnalyzeSnapshot = useCallback(async (snapId: string) => {
const snap = snapshots.find(s => s.id === snapId);
if (!snap) return;
// 标记本地 analyzing(立即显示 spinner)
setAnalyzingIds(prev => new Set(prev).add(snapId));
setAiErrors(prev => { const next = { ...prev }; delete next[snapId]; return next; });
try {
// 先同步 providers 到主进程(主进程 AI 执行需要解密的 API key)
const api = (window as any).netcatty;
try {
const providersRaw = localStorage.getItem("netcatty_ai_providers_v1");
if (providersRaw && api?.aiSyncProviders) {
const providers = JSON.parse(providersRaw);
await api.aiSyncProviders(providers);
}
} catch (_) {}
// 主进程独立执行 —— 页面切换不影响
await inspectionReportApi.runAiSummary(snapId);
// 主进程 setImmediate 异步执行,这里已经返回;靠事件/loadSnapshots 刷新状态
// 立即 load 一次,如果主进程刚好跑完则直接显示结果
setTimeout(() => { void loadSnapshots(); }, 800);
} catch (e: any) {
// IPC 层报错(provider 未配置等)
setAiErrors(prev => ({ ...prev, [snapId]: e?.message || String(e) }));
setAnalyzingIds(prev => { const s = new Set(prev); s.delete(snapId); return s; });
toast.error("AI 分析失败:" + (e?.message || String(e)));
}
}, [snapshots, loadSnapshots]);
const loadDailyReports = useCallback(async () => {
setDailyLoading(true);
try {
const data = await inspectionDailyReportApi.list(30);
setDailyReports(data || []);
} catch (e) {
console.error("[ReportPage] listDailyReports failed:", e);
} finally {
setDailyLoading(false);
}
}, []);
const loadSchedule = useCallback(async () => {
try {
const cfg = await inspectionReportScheduleApi.get();
setSchedule(cfg);
} catch (e) {
console.error("[ReportPage] getReportSchedule failed:", e);
}
}, []);
useEffect(() => {
void loadSnapshots();
void loadDailyReports();
void loadSchedule();
// 加载所有 vault 设备(用于 SchedulePanel 多选)
buildAllDeviceTargets().then(r => setAllHosts(r.allHosts)).catch(() => {});
// 查询主进程快照执行状态(页面切换回来时主进程可能还在跑)
void inspectionReportApi.isSnapshotRunning().then(r => {
if (r?.running) setRunning(true);
}).catch(() => {});
}, [loadSnapshots, loadDailyReports, loadSchedule]);
// 订阅主进程快照完成广播 —— 页面切换后回来也能收到通知自动刷新
useEffect(() => {
const api = (window as any).netcatty;
if (!api?.on || typeof api.on !== "function") return;
const handler = (_data: any) => {
void loadSnapshots();
};
api.on("inspection:reportSnapshotCompleted", handler);
return () => {
try { api.off?.("inspection:reportSnapshotCompleted", handler); } catch {}
};
}, [loadSnapshots]);
// 订阅主进程巡检执行状态(running / done)—— 控制"立即巡检"按钮显示
useEffect(() => {
const api = (window as any).netcatty;
if (!api?.on || typeof api.on !== "function") return;
const handler = ({ running }: any) => {
setRunning(!!running);
if (!running) void loadSnapshots();
};
api.on("inspection:reportSnapshotStatusChanged", handler);
return () => {
try { api.off?.("inspection:reportSnapshotStatusChanged", handler); } catch {}
};
}, [loadSnapshots]);
// ─── 定时巡检 ───
// 【已废弃】2026-10-15 移除 —— 定时巡检已迁移到主进程 reportScheduler 独立执行
// 不再依赖用户打开巡检报告页面(此组件 mount),关闭页面也会定时跑
// 之前这里 + useReportAutoScheduler + 主进程三路并行,每次触发产生重复快照
// 手动触发巡检请直接点击页面上的"立即巡检"按钮(handleRunSnapshot)
// 订阅主进程 AI 汇总状态广播 —— 主进程独立执行,页面切换也不影响
useEffect(() => {
const api = (window as any).netcatty;
if (!api?.on || typeof api.on !== "function") return;
const handler = ({ snapshotId, status, error }: any) => {
// 清除本地 analyzing 标记(主进程状态已进 JSON)
setAnalyzingIds(prev => {
const s = new Set(prev); s.delete(snapshotId); return s;
});
if (status === "failed" && error) {
setAiErrors(prev => ({ ...prev, [snapshotId]: error }));
} else if (status === "done") {
setAiErrors(prev => { const n = { ...prev }; delete n[snapshotId]; return n; });
setAiSummaries(prev => { const n = { ...prev }; delete n[snapshotId]; return n; });
}
// 刷新快照列表(从 JSON 读 ai_summary_status + ai_summary)
void loadSnapshots();
};
api.on("inspection:reportAiStatusChanged", handler);
return () => {
try { api.off?.("inspection:reportAiStatusChanged", handler); } catch {}
};
}, [loadSnapshots]);
// 切页面回来:只刷新快照列表(不自动跑 AI,AI 需用户手动点击触发)
useEffect(() => {
void loadSnapshots();
}, [loadSnapshots, snapshots.length]);
// 24h 统计
const stats24h = useMemo(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const recent = snapshots.filter(s => s.timestamp >= cutoff);
let totalCmds = 0, okCmds = 0;
for (const snap of recent) {
for (const dev of snap.devices) {
for (const cmd of dev.commands) {
totalCmds++;
if (cmd.ok) okCmds++;
}
}
}
return {
snapCount: recent.length,
deviceChecks: recent.reduce((a, s) => a + s.devices.length, 0),
totalCmds, okCmds,
failCmds: totalCmds - okCmds,
failRate: totalCmds > 0 ? ((totalCmds - okCmds) / totalCmds * 100).toFixed(1) : "0",
};
}, [snapshots]);
const handleRunSnapshot = useCallback(async (trigger: "manual" | "scheduled" = "manual") => {
// 记录执行时间(手动 + 定时共用,避免重复触发)
lastRunAtRef.current = Date.now();
setRunning(true);
try {
// 先同步 AI providers 到主进程(快照生成后主进程 setImmediate 自动跑 AI 汇总需要)
const api = (window as any).netcatty;
try {
const providersRaw = localStorage.getItem("netcatty_ai_providers_v1");
if (providersRaw && api?.aiSyncProviders) {
await api.aiSyncProviders(JSON.parse(providersRaw));
}
} catch (_) {}
// 按调度配置过滤设备
// null/undefined = 全部;[] = 一个都不选(直接拦截)
const filter = schedule?.enabledDeviceIds;
if (filter && filter.length === 0) {
toast.error("配置当前为「不巡检任何设备」,请先在 ⚙️ 报告设置中选择设备");
return;
}
const { targets } = await buildAllDeviceTargets(filter ?? undefined);
if (targets.length === 0) {
toast.error("没有可选设备:Vault 为空或配置过滤后无匹配");
return;
}
if (trigger === "manual") {
toast.info(`正在巡检 ${targets.length} 台设备${filter ? "(已按配置过滤)" : "(全部)"}...`);
} else {
console.log(`[ReportPage] 定时巡检触发,目标 ${targets.length} 台${filter ? "(已按配置过滤)" : "(全部)"}`);
}
const result = await inspectionReportApi.runSnapshot(targets, trigger);
if (result.ok && result.snapshot) {
toast.success(`快照完成(${formatDuration(result.snapshot.duration_ms)})`);
await loadSnapshots();
setExpandedSnap(result.snapshot.id);
// AI 汇总需运维手动点击触发(不自动跑,节省 token)
} else {
toast.error("快照执行失败:" + (result.error || "未知错误"));
}
} catch (e: any) {
console.error("[ReportPage] runSnapshot failed:", e);
toast.error("快照失败:" + (e?.message || String(e)));
} finally {
setRunning(false);
}
}, [loadSnapshots, schedule]);
const handleDelete = useCallback(async (id: string) => {
try { await inspectionReportApi.deleteSnapshot(id); toast.success("已删除"); await loadSnapshots(); }
catch { toast.error("删除失败"); }
}, [loadSnapshots]);
const handleClearAll = useCallback(async () => {
if (!confirm("确定清空所有快照?此操作不可恢复。")) return;
try { await inspectionReportApi.clearSnapshots(); await loadSnapshots(); toast.success("已清空"); }
catch { toast.error("清空失败"); }
}, [loadSnapshots]);
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
const handlePreviewSnapshotPdf = useCallback(async (snapshotId: string) => {
if (IS_REMOTE) {
const token = (window as any).__REMOTE_TOKEN__ || "";
window.open(`/api/report-preview?mode=snapshot&id=${encodeURIComponent(snapshotId)}&token=${encodeURIComponent(token)}`, "_blank");
return;
}
try {
const result = await inspectionReportApi.previewSnapshotPdf(snapshotId);
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
} catch (e: any) {
toast.error("预览失败:" + (e?.message || String(e)));
}
}, []);
/** 下载 = 让用户选路径(showSaveDialog)+ 存文件(不自动打开) */
const handleDownloadSnapshotPdf = useCallback(async (snapshotId: string) => {
try {
const snap = snapshots.find(s => s.id === snapshotId);
if (!snap) { console.warn("[DownloadPDF] 找不到快照", snapshotId); return; }
const api = (window as any).netcatty;
const defaultName = `巡检快照_${formatTime(snap.timestamp).replace(/[: ]/g, "_")}.pdf`;
console.log("[DownloadPDF] 开始 defaultName=", defaultName, "netcatty=", !!api, "showSaveDialog=", !!api?.showSaveDialog);
let customPath: string | undefined;
if (api?.showSaveDialog) {
const r = await api.showSaveDialog(defaultName, [{ name: "PDF", extensions: ["pdf"] }]);
console.log("[DownloadPDF] showSaveDialog 返回=", r);
if (!r?.canceled && r?.filePath) customPath = r.filePath;
} else {
console.warn("[DownloadPDF] api.showSaveDialog 不可用");
}
if (!customPath) { console.warn("[DownloadPDF] customPath 为空,取消下载"); return; }
console.log("[DownloadPDF] 调用 exportSnapshotPdf snapshotId=", snapshotId, "customPath=", customPath);
const res = await inspectionReportApi.exportSnapshotPdf({
snapshotId, aiSummary: snap.ai_summary, customPath,
});
console.log("[DownloadPDF] exportSnapshotPdf 返回=", res);
if (res.ok && res.filePath) toast.success(`已保存:${res.filePath}`);
else toast.error("保存失败:" + (res.error || "未知错误"));
} catch (e: any) {
console.error("[DownloadPDF] 异常:", e);
toast.error("保存失败:" + (e?.message || String(e)));
}
}, [snapshots]);
const handlePreviewDailyPdf = useCallback(async (reportId: string) => {
if (IS_REMOTE) {
const token = (window as any).__REMOTE_TOKEN__ || "";
window.open(`/api/report-preview?mode=daily&id=${encodeURIComponent(reportId)}&token=${encodeURIComponent(token)}`, "_blank");
return;
}
try {
const result = await inspectionDailyReportApi.previewPdf(reportId);
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
} catch (e: any) {
toast.error("预览失败:" + (e?.message || String(e)));
}
}, []);
const handleGenerateDailyReport = useCallback(async (mode: "provisional" | "finalized" = "provisional") => {
setGenerating(true);
try {
const result = await inspectionDailyReportApi.generate({ mode, trigger: "manual" });
if (result.ok && result.report) {
toast.success(`日报告已生成(${result.report.date})`);
await loadDailyReports();
} else {
toast.error("生成失败:" + (result.error || "未知错误"));
}
} catch (e: any) {
toast.error("生成失败:" + (e?.message || String(e)));
} finally {
setGenerating(false);
}
}, [loadDailyReports]);
const handleDeleteDailyReport = useCallback(async (id: string) => {
try { await inspectionDailyReportApi.delete(id); await loadDailyReports(); toast.success("已删除"); }
catch { toast.error("删除失败"); }
}, [loadDailyReports]);
const handleSaveSchedule = useCallback(async (updated: Partial<ReportSchedule>) => {
try {
const saved = await inspectionReportScheduleApi.save(updated);
setSchedule(saved);
toast.success("配置已保存");
} catch (e) {
toast.error("保存失败");
}
}, []);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
{/* Header */}
<div className="shrink-0 border-b border-border/60 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-6 w-6 text-blue-500" />
<div>
<h1 className="text-xl font-semibold tracking-tight">巡检报告</h1>
<p className="text-sm text-muted-foreground">
每小时巡检所有设备,每台 15-20 条命令,记录完整 stdout
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setScheduleOpen(v => !v)}>
<Settings2 className="mr-2 h-4 w-4" /> 报告设置
{schedule?.enabled && <span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-green-500" />}
</Button>
<Button variant="outline" size="sm" onClick={() => void loadSnapshots()} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} /> 刷新
</Button>
<Button size="sm" onClick={() => void handleRunSnapshot()} disabled={running}>
{running ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
{running ? "巡检中..." : "立即巡检"}
</Button>
{snapshots.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleClearAll} className="text-red-500 hover:text-red-500">
<Trash2 className="mr-2 h-4 w-4" /> 清空
</Button>
)}
</div>
</div>
{/* Stats row */}
<div className="mt-4 grid grid-cols-4 gap-3">
<StatCard icon={<FileText className="h-4 w-4" />} label="24h 快照" value={stats24h.snapCount} />
<StatCard icon={<Server className="h-4 w-4" />} label="设备巡检次数" value={stats24h.deviceChecks} />
<StatCard icon={<Terminal className="h-4 w-4" />} label="命令成功" value={`${stats24h.okCmds} / ${stats24h.totalCmds}`} />
<StatCard icon={<Activity className="h-4 w-4" />} label="失败率" value={`${stats24h.failRate}%`} accent={Number(stats24h.failRate) > 5 ? "red" : "green"} />
</div>
</div>
{/* 设置面板(可折叠) */}
{scheduleOpen && schedule && (
<div className="shrink-0 border-b border-border/60 px-6 py-3">
<SchedulePanel
schedule={schedule}
allHosts={allHosts}
onSave={handleSaveSchedule}
onToggleScheduler={async (enabled) => {
await inspectionReportScheduleApi.toggleScheduler(enabled);
await loadSchedule();
}}
/>
</div>
)}
{/* Tabs */}
<Tabs defaultValue="snapshots" className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 px-6 pt-4">
<TabsList>
<TabsTrigger value="snapshots"><FileText className="mr-2 h-4 w-4" /> 巡检快照</TabsTrigger>
<TabsTrigger value="daily"><Calendar className="mr-2 h-4 w-4" /> 日运行报告</TabsTrigger>
</TabsList>
</div>
{/* Snapshots */}
<TabsContent value="snapshots" className="flex min-h-0 flex-1 flex-col px-6 pt-4">
<ScrollArea className="flex-1 pr-2">
{loading ? (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> 加载中...
</div>
) : snapshots.length === 0 ? (
<EmptyState onRun={() => void handleRunSnapshot()} />
) : (
<div className="space-y-3">
{snapshots.map(snap => (
<SnapshotCard
key={snap.id}
snap={snap}
expanded={expandedSnap === snap.id}
onToggle={() => setExpandedSnap(prev => (prev === snap.id ? null : snap.id))}
expandedDevice={expandedDevice}
onDeviceToggle={id => setExpandedDevice(prev => (prev === id ? null : id))}
onDelete={() => void handleDelete(snap.id)}
onPreviewPdf={() => void handlePreviewSnapshotPdf(snap.id)}
onAiAnalyze={() => void handleAiAnalyzeSnapshot(snap.id)}
aiSummary={getAiSummaryFor(snap.id)}
aiState={getAiState(snap.id)}
aiError={aiErrors[snap.id]}
/>
))}
</div>
)}
</ScrollArea>
</TabsContent>
{/* Daily Report */}
<TabsContent value="daily" className="flex min-h-0 flex-1 flex-col px-6 pt-4">
{/* Daily actions */}
<div className="mb-3 flex shrink-0 items-center justify-between">
<p className="text-sm text-muted-foreground">
日报告聚合当天所有巡检快照数据,随时可手动生成当日快照汇总
</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => void loadDailyReports()} disabled={dailyLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${dailyLoading ? "animate-spin" : ""}`} /> 刷新
</Button>
<Button size="sm" onClick={() => void handleGenerateDailyReport("provisional")} disabled={generating}>
{generating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ListChecks className="mr-2 h-4 w-4" />}
{generating ? "生成中..." : "📝 生成今日报告"}
</Button>
</div>
</div>
<ScrollArea className="flex-1 pr-2">
{dailyReports.length === 0 ? (
<EmptyDailyReport onGenerate={() => void handleGenerateDailyReport("provisional")} />
) : (
<div className="space-y-3">
{dailyReports.map(report => (
<DailyReportCard
key={report.id}
report={report}
onDelete={() => void handleDeleteDailyReport(report.id)}
onPreviewPdf={() => void handlePreviewDailyPdf(report.id)}
/>
))}
</div>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</div>
);
}
export default ReportPage;
// ─── 子组件 ────────────────────────────────────
function StatCard({ icon, label, value, accent }: {
icon: React.ReactNode; label: string; value: React.ReactNode;
accent?: "red" | "green";
}) {
return (
<Card className="shadow-sm">
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
<span className="text-xs">{label}</span>
</div>
<div className={`text-lg font-semibold ${
accent === "red" ? "text-red-500" : accent === "green" ? "text-green-500" : ""
}`}>{value}</div>
</div>
</Card>
);
}
function EmptyState({ onRun }: { onRun: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<FileText className="h-12 w-12 text-muted-foreground/40" />
<h3 className="mt-4 text-base font-medium">暂无巡检报告</h3>
<p className="mt-1 text-sm text-muted-foreground">
点击"立即巡检"手动触发一次,或等待调度器自动执行
</p>
<Button onClick={onRun} className="mt-4">
<Play className="mr-2 h-4 w-4" /> 立即巡检
</Button>
</div>
);
}
function SnapshotCard({
snap, expanded, onToggle, expandedDevice, onDeviceToggle,
onDelete, onPreviewPdf, onAiAnalyze, aiSummary, aiState, aiError,
}: {
snap: ReportSnapshot; expanded: boolean; onToggle: () => void;
expandedDevice: string | null; onDeviceToggle: (id: string) => void;
onDelete: () => void; onPreviewPdf: () => void;
onAiAnalyze: () => void; aiSummary?: any;
aiState: "idle" | "analyzing" | "done" | "failed";
aiError?: string;
}) {
const okDevices = snap.devices.filter(d => d.ok).length;
const totalCmds = snap.devices.reduce((a, d) => a + d.commands.length, 0);
const okCmds = snap.devices.reduce((a, d) => a + d.commands.filter(c => c.ok).length, 0);
// AI 状态 pill 颜色
const aiPill = {
idle: { cls: "bg-muted text-muted-foreground", icon: "·", label: "AI 未分析" },
analyzing: { cls: "bg-purple-500/15 text-purple-600", icon: "", label: "AI 分析中..." },
done: { cls: "bg-green-500/15 text-green-600", icon: "✓", label: "AI 已分析" },
failed: { cls: "bg-red-500/15 text-red-600", icon: "✗", label: "AI 失败" },
}[aiState];
return (
<Card className="shadow-sm">
{/* Header:点击展开 */}
<div className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-muted/30" onClick={onToggle}>
{expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<div className="flex-1">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-blue-500" />
<span className="font-medium">{formatTime(snap.timestamp)}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
snap.trigger === "scheduled" ? "bg-purple-500/15 text-purple-600" : "bg-blue-500/15 text-blue-600"
}`}>{snap.trigger === "scheduled" ? "定时" : "手动"}</span>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Server className="h-3 w-3" />{snap.devices.length} 台</span>
<span>{okDevices}/{snap.devices.length} 正常</span>
<span>{okCmds}/{totalCmds} 命令成功</span>
<span>{formatDuration(snap.duration_ms)}</span>
{/* AI 状态 pill —— 放在 header 右侧作为整体状态指示 */}
<button
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition ${aiPill.cls} ${
aiState === "analyzing" ? "flex items-center gap-1" : "hover:opacity-80"
}`}
onClick={(e) => {
e.stopPropagation();
if (aiState === "failed" || aiState === "idle") onAiAnalyze();
}}
title={aiState === "failed" ? (aiError || "点击重试") : aiLabelHint(aiState)}
>
{aiState === "analyzing" ? <Loader2 className="h-3 w-3 animate-spin" /> : <span>{aiPill.icon}</span>}
{aiPill.label}
</button>
{/* done 状态:header 右侧直接给 PDF 预览按钮 —— 不用展开卡片就能点(PDF 预览窗口自带下载功能) */}
{aiState === "done" && (
<Button variant="outline" size="sm" className="h-6 gap-1 border-blue-500/30 px-2 text-[10px] text-blue-600 hover:text-blue-700"
onClick={(e) => { e.stopPropagation(); onPreviewPdf(); }}
title="内嵌预览 PDF(预览窗口可直接下载)">
<FileText className="h-3 w-3" />
预览
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => { e.stopPropagation(); onDelete(); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 展开区 */}
{expanded && (
<div className="space-y-3 border-t border-border/40 bg-muted/10 p-3">
{/* ═══ AI 汇总区块(第一优先级,默认展开) ═══ */}
<AiSummaryBlock
aiState={aiState}
aiSummary={aiSummary}
aiError={aiError}
onAiAnalyze={onAiAnalyze}
onPreviewPdf={onPreviewPdf}
/>
{/* ═══ 原始命令详情(可折叠,排在下面) ═══ */}
<details open className="rounded border border-border/40 bg-background/60">
<summary className="cursor-pointer select-none px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground">
📋 原始命令详情({snap.devices.length} 台设备)
</summary>
<div className="space-y-2 border-t border-border/30 p-2">
{snap.devices.map(dev => (
<DeviceBlock key={dev.device_id} dev={dev}
expanded={expandedDevice === dev.device_id}
onToggle={() => onDeviceToggle(dev.device_id)} />
))}
</div>
</details>
</div>
)}
</Card>
);
}
function aiLabelHint(state: "idle" | "analyzing" | "done" | "failed") {
switch (state) {
case "idle": return "点击开始 AI 分析";
case "analyzing": return "AI 正在分析快照...";
case "done": return "AI 汇总已完成";
case "failed": return "AI 分析失败,点击重试";
}
}
/** AI 汇总区块组件 —— 四种状态渲染不同内容 */
function AiSummaryBlock({
aiState, aiSummary, aiError, onAiAnalyze, onPreviewPdf,
}: {
aiState: "idle" | "analyzing" | "done" | "failed";
aiSummary?: any;
aiError?: string;
onAiAnalyze: () => void;
onPreviewPdf: () => void;
}) {
// 等待状态
if (aiState === "analyzing") {
return (
<div className="rounded-md border border-purple-500/30 bg-purple-500/5 p-4 text-sm">
<div className="flex items-center gap-3 text-purple-600">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="font-medium">AI 正在分析巡检数据...</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
观察温度、CPU、风扇、NTP、端口状态等细节,识别隐藏风险
</p>
</div>
);
}
// 未分析(idle)
if (aiState === "idle") {
return (
<div className="flex items-center justify-between rounded-md border border-border/40 bg-background/60 px-4 py-3 text-sm">
<div className="text-muted-foreground">
<span className="mr-2">🤖</span>还没 AI 分析,点击生成智能汇总
</div>
<Button size="sm" variant="outline" onClick={onAiAnalyze} className="text-purple-600 hover:text-purple-700">
<span className="mr-1">🤖</span> 开始 AI 分析
</Button>
</div>
);
}
// 分析失败
if (aiState === "failed") {
return (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 text-red-600 font-medium">
<span>✗</span> AI 分析失败
</div>
<p className="mt-1 text-xs text-red-500/80 break-all">{aiError || "未知错误"}</p>
</div>
<Button size="sm" variant="outline" onClick={onAiAnalyze} className="border-red-500/50 text-red-600 hover:text-red-700">
<RefreshCw className="mr-1 h-3 w-3" /> 重试
</Button>
</div>
</div>
);
}
// 成功(done)—— 完整 AI 汇总 + PDF 按钮
return (
<div className="rounded-md border border-purple-500/30 bg-purple-500/5 p-3 text-xs">
{/* 顶部标题 + PDF 按钮 */}
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2 font-medium text-purple-600">
<span className="text-sm">🤖</span> AI 智能分析
</div>
<Button size="sm" variant="outline" onClick={onPreviewPdf} className="h-7 text-xs text-blue-600 hover:text-blue-700 border-blue-500/30">
<FileText className="mr-1 h-3.5 w-3.5" /> 预览 PDF
</Button>
</div>
{/* 健康摘要 */}
<div className="mb-2 text-foreground/80">{aiSummary?.health_summary}</div>
{/* 离线 */}
{aiSummary?.offline_devices?.length > 0 && (
<div className="mb-2">
<div className="mb-1 font-medium text-red-500/80">🔴 离线设备({aiSummary.offline_devices.length})</div>
<ul className="ml-4 list-disc space-y-0.5 text-foreground/70">
{aiSummary.offline_devices.map((o: any, i: number) => (
<li key={i} className="font-mono text-[11px]">{o.device} — <span className="text-muted-foreground">{o.reason}</span></li>
))}
</ul>
</div>
)}
{/* 隐藏风险 */}
{aiSummary?.hidden_risks?.length > 0 && (
<div className="mb-2">
<div className="mb-1 font-medium text-amber-500/80">⚠️ 隐藏风险({aiSummary.hidden_risks.length})</div>
<ul className="ml-4 list-disc space-y-0.5 text-foreground/70">
{aiSummary.hidden_risks.map((h: any, i: number) => (
<li key={i}>
<span className="font-mono text-[11px]">{h.device}</span> — {h.observation}
<span className={`ml-1 rounded px-1 text-[9px] ${
h.severity === "critical" ? "bg-red-500/15 text-red-600" :
h.severity === "warning" ? "bg-amber-500/15 text-amber-600" :
"bg-blue-500/15 text-blue-600"
}`}>{h.severity}</span>
</li>
))}
</ul>
</div>
)}
{/* 设备运行概览 */}
{aiSummary?.device_status?.length > 0 && (
<div className="mb-2">
<div className="mb-1 font-medium text-blue-500/80">📋 设备运行概览</div>
<div className="space-y-1 pl-2">
{aiSummary.device_status.map((d: any, i: number) => (
<div key={i} className="rounded border border-border/40 bg-background/60 px-2 py-1">
<div className="flex items-center gap-2 text-[11px]">
<span className="font-mono font-medium">{d.device}</span>
<span className={`rounded px-1 text-[9px] ${d.running_ok ? "bg-green-500/15 text-green-600" : "bg-red-500/15 text-red-600"}`}>
{d.running_ok ? "正常" : "异常"}
</span>
</div>
{d.highlights?.length > 0 && (
<div className="mt-0.5 text-[10px] text-foreground/70">
<span className="text-muted-foreground">观察:</span>{d.highlights.join(";")}
</div>
)}
{d.issues?.length > 0 && (
<div className="mt-0.5 text-[10px] text-red-500/80">
<span className="text-muted-foreground">问题:</span>{d.issues.join(";")}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* 处置建议 */}
{aiSummary?.recommendations?.length > 0 && (
<div>
<div className="mb-1 font-medium text-blue-500/80">💡 处置建议</div>
<ol className="ml-4 list-decimal space-y-0.5 text-foreground/70">
{aiSummary.recommendations.map((r: string, i: number) => (
<li key={i}>{r}</li>
))}
</ol>
</div>
)}
</div>
);
}
function DeviceBlock({ dev, expanded, onToggle }: {
dev: ReportSnapshot["devices"][number]; expanded: boolean; onToggle: () => void;
}) {
const okCmds = dev.commands.filter(c => c.ok).length;
const failCmds = dev.commands.length - okCmds;
return (
<div className="rounded-md border border-border/60 bg-background">
<div className="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-muted/30" onClick={onToggle}>
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
{dev.ok ? <Wifi className="h-3.5 w-3.5 text-green-500" /> : <WifiOff className="h-3.5 w-3.5 text-red-500" />}
<span className="font-mono text-sm font-medium">{dev.hostname}</span>
<span className="text-xs text-muted-foreground">{CATALOG_LABEL[dev.catalog_id] || dev.catalog_id}</span>
<span className="ml-auto text-xs text-muted-foreground">
{okCmds}/{dev.commands.length}
{failCmds > 0 && <span className="ml-1 text-red-500">({failCmds} 失败)</span>}
</span>
</div>
{expanded && (
<div className="border-t border-border/40 bg-muted/20 p-2">
<div className="space-y-1.5">
{dev.commands.map((cmd, idx) => (
<details key={idx} className="group rounded border border-border/40 bg-background">
<summary className="flex cursor-pointer items-center gap-2 px-2 py-1 text-xs hover:bg-muted/30">
{cmd.ok ? <span className="inline-block h-1.5 w-1.5 rounded-full bg-green-500" />
: <span className="inline-block h-1.5 w-1.5 rounded-full bg-red-500" />}
<code className="font-mono text-[11px]">{cmd.cmd}</code>
<span className="ml-auto text-muted-foreground">{cmd.purpose} · {formatDuration(cmd.duration_ms)}</span>
</summary>
{(cmd.stdout || cmd.stderr) && (
<pre className="max-h-60 overflow-auto border-t border-border/40 bg-black/5 p-2 font-mono text-[11px] leading-relaxed text-foreground">
{cmd.stdout || cmd.stderr}
</pre>
)}
</details>
))}
</div>
</div>
)}
</div>
);
}
// ─── 日运行报告(Phase 1 简化) ─────────────────
function DailyReportView({ snapshots }: { snapshots: ReportSnapshot[] }) {
if (snapshots.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/40" />
<h3 className="mt-4 text-base font-medium">暂无数据</h3>
<p className="mt-1 text-sm text-muted-foreground">至少跑一次巡检快照后,这里会展示 24 小时汇总统计</p>
</div>
);
}
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const recent = snapshots.filter(s => s.timestamp >= cutoff);
const deviceMap = new Map<string, { hostname: string; catalog_id: string; totalCmds: number; okCmds: number; failSnapshots: number }>();
for (const snap of recent) {
for (const dev of snap.devices) {
const cur = deviceMap.get(dev.device_id) || {
hostname: dev.hostname, catalog_id: dev.catalog_id,
totalCmds: 0, okCmds: 0, failSnapshots: 0,
};
cur.totalCmds += dev.commands.length;
cur.okCmds += dev.commands.filter(c => c.ok).length;
if (!dev.ok) cur.failSnapshots++;
deviceMap.set(dev.device_id, cur);
}
}
const devices = Array.from(deviceMap.entries())
.map(([id, d]) => ({ id, ...d }))
.sort((a, b) => b.totalCmds - a.totalCmds);
const cmdMap = new Map<string, { total: number; ok: number }>();
for (const snap of recent) {
for (const dev of snap.devices) {
for (const cmd of dev.commands) {
const cur = cmdMap.get(cmd.cmd) || { total: 0, ok: 0 };
cur.total++;
if (cmd.ok) cur.ok++;
cmdMap.set(cmd.cmd, cur);
}
}
}
const cmds = Array.from(cmdMap.entries())
.map(([cmd, v]) => ({ cmd, ...v, failRate: v.total - v.ok }))
.sort((a, b) => b.failRate - a.failRate);
const sumCmds = Array.from(deviceMap.values()).reduce((a, d) => a + d.totalCmds, 0);
const sumOk = Array.from(deviceMap.values()).reduce((a, d) => a + d.okCmds, 0);
return (
<div className="space-y-4">
{/* 概览 */}
<Card className="shadow-sm">
<div className="border-b border-border/40 px-4 py-3">
<h3 className="flex items-center gap-2 text-base font-medium">
<Calendar className="h-4 w-4 text-blue-500" />
过去 24 小时汇总
</h3>
</div>
<div className="p-4">
<div className="grid grid-cols-4 gap-4 text-center">
<MetricItem label="快照次数" value={recent.length} />
<MetricItem label="检查设备" value={deviceMap.size} />
<MetricItem label="总命令执行" value={sumCmds} />
<MetricItem label="整体成功率" value={`${sumCmds > 0 ? ((sumOk / sumCmds) * 100).toFixed(1) : "0"}%`} />
</div>
</div>
</Card>
{/* 设备列表 */}
<Card className="shadow-sm">
<div className="border-b border-border/40 px-4 py-3">
<h3 className="flex items-center gap-2 text-base font-medium">
<Server className="h-4 w-4 text-blue-500" />
设备巡检统计
</h3>
</div>
<div className="p-4">
<div className="space-y-2">
{devices.map(d => (
<div key={d.id} className="flex items-center gap-3 rounded-md border border-border/40 px-3 py-2 text-sm">
{d.failSnapshots > 0 ? <Wifi className="h-3.5 w-3.5 text-amber-500" /> : <Wifi className="h-3.5 w-3.5 text-green-500" />}
<span className="font-mono">{d.hostname}</span>
<span className="text-xs text-muted-foreground">{CATALOG_LABEL[d.catalog_id] || d.catalog_id}</span>
<span className="ml-auto text-xs">
成功率 {d.totalCmds > 0 ? ((d.okCmds / d.totalCmds) * 100).toFixed(1) : "0"}%
({d.okCmds}/{d.totalCmds})
</span>
{d.failSnapshots > 0 && (
<span className="rounded bg-red-500/10 px-2 py-0.5 text-[10px] text-red-600">{d.failSnapshots} 次失败</span>
)}
</div>
))}
</div>
</div>
</Card>
{/* 失败命令 Top */}
{cmds.filter(c => c.failRate > 0).length > 0 && (
<Card className="shadow-sm">
<div className="border-b border-border/40 px-4 py-3">
<h3 className="flex items-center gap-2 text-base font-medium">
<Activity className="h-4 w-4 text-amber-500" />
命令失败 Top
</h3>
</div>
<div className="p-4">
<div className="space-y-1.5">
{cmds.filter(c => c.failRate > 0).slice(0, 10).map(c => (
<div key={c.cmd} className="flex items-center gap-3 rounded border border-border/40 px-3 py-1.5 text-xs">
<code className="flex-1 truncate font-mono">{c.cmd}</code>
<span className="text-muted-foreground">{c.ok}/{c.total} 成功</span>
<span className="text-red-500">{((c.total - c.ok) / c.total * 100).toFixed(0)}% 失败</span>
</div>
))}
</div>
</div>
</Card>
)}
</div>
);
}
function MetricItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="rounded-md border border-border/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
}
// ─── 日运行报告(消费 DailyReport[] 类型) ──────────
function EmptyDailyReport({ onGenerate }: { onGenerate: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/40" />
<h3 className="mt-4 text-base font-medium">暂无日运行报告</h3>
<p className="mt-1 text-sm text-muted-foreground">
至少跑一次巡检快照后,点击"生成今日报告"即可出当日汇总
</p>
<Button onClick={onGenerate} className="mt-4">
<ListChecks className="mr-2 h-4 w-4" /> 生成今日报告
</Button>
</div>
);
}
function DailyReportCard({ report, onDelete, onPreviewPdf }: {
report: DailyReport; onDelete: () => void; onPreviewPdf: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const s = report.summary;
const isFinalized = report.mode === "finalized";
return (
<Card className="shadow-sm">
<div className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-muted/30"
onClick={() => setExpanded(v => !v)}>
{expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<div className="flex-1">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-blue-500" />
<span className="font-medium">{report.date}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
isFinalized
? "bg-green-500/15 text-green-600"
: "bg-amber-500/15 text-amber-600"
}`}>{isFinalized ? "最终版" : "当前快照"}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
report.trigger === "scheduled"
? "bg-purple-500/15 text-purple-600"
: "bg-blue-500/15 text-blue-600"
}`}>{report.trigger === "scheduled" ? "定时" : "手动"}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{s.snapshotCount} 次快照 · {s.deviceCount} 台设备 · {s.totalCmds} 条命令
</div>
</div>
<div className="flex items-center gap-4 text-xs">
<span>成功率 {s.totalCmds > 0 ? `${s.okCmds}/${s.totalCmds}` : "0"}</span>
<span className={s.failRate > 5 ? "text-red-500" : "text-green-500"}>
失败 {s.failRate}%
</span>
<Button variant="ghost" size="sm" className="h-7 gap-1 text-xs text-blue-500 hover:text-blue-600"
onClick={(e) => { e.stopPropagation(); onPreviewPdf(); }}>
<FileText className="h-3.5 w-3.5" /> PDF 预览
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => { e.stopPropagation(); onDelete(); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{expanded && (
<div className="space-y-3 border-t border-border/40 bg-muted/10 p-4">
{/* 设备成功率 */}
<div>
<h4 className="mb-2 text-xs font-medium text-muted-foreground">设备成功率</h4>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Object.entries(s.deviceSuccessRate).map(([devId, rate]) => (
<div key={devId} className="flex items-center gap-2 rounded border border-border/40 bg-background px-2 py-1.5 text-xs">
{rate >= 0.98 ? <Wifi className="h-3 w-3 text-green-500" />
: rate >= 0.8 ? <Wifi className="h-3 w-3 text-amber-500" />
: <WifiOff className="h-3 w-3 text-red-500" />}
<span className="truncate font-mono">{devId.slice(0, 12)}</span>
<span className="ml-auto">{(rate * 100).toFixed(0)}%</span>
</div>
))}
</div>
</div>
{/* Top 失败命令 */}
{s.topFailedCommands.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
失败命令 Top {s.topFailedCommands.length > 10 ? `10 / ${s.topFailedCommands.length}` : s.topFailedCommands.length}
</h4>
<div className="space-y-1">
{s.topFailedCommands.slice(0, 10).map((f, idx) => (
<div key={idx} className="flex items-center gap-2 rounded border border-border/40 bg-background px-2 py-1 text-xs">
<span className="text-muted-foreground">#{idx + 1}</span>
<code className="flex-1 truncate font-mono">{f.cmd}</code>
<span className="text-muted-foreground">{f.device_id}</span>
<span className="text-red-500">× {f.count}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</Card>
);
}
// ─── 调度设置面板 ───────────────────────────────────
function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
schedule: ReportSchedule;
allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }>;
onSave: (updated: Partial<ReportSchedule>) => void;
onToggleScheduler: (enabled: boolean) => void;
}) {
const [local, setLocal] = useState<ReportSchedule>(schedule);
const [saving, setSaving] = useState(false);
useEffect(() => { setLocal(schedule); }, [schedule]);
const handleSave = async () => {
setSaving(true);
await onSave(local);
setSaving(false);
};
// 设备多选 helpers
// enabledDeviceIds 约定:null/undefined = 全部,[] = 一个都不选,[id1,id2] = 指定
const selectedAll = !local.enabledDeviceIds; // null / undefined → 全部
const toggleAllDevices = (selectAll: boolean) => {
setLocal(p => ({ ...p, enabledDeviceIds: selectAll ? null : [] }));
};
const toggleDevice = (deviceId: string) => {
setLocal(p => {
const cur = p.enabledDeviceIds;
if (!cur) {
// 当前是"全部" —— 取消这一台变成"除了这台"
return { ...p, enabledDeviceIds: allHosts.filter(h => h.id !== deviceId).map(h => h.id) };
}
const included = cur.includes(deviceId);
const next = included ? cur.filter(id => id !== deviceId) : [...cur, deviceId];
return { ...p, enabledDeviceIds: next };
});
};
const CATALOG_LABEL: Record<string, string> = {
linux_server: "Linux",
huawei_h3c_switch: "华为/H3C",
cisco_switch: "Cisco",
huawei_firewall: "华为FW",
fortinet_firewall: "飞塔FW",
windows_server: "Windows",
};
const enabledCount = selectedAll ? allHosts.length : (local.enabledDeviceIds?.length || 0);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-blue-500" />
<h3 className="text-sm font-semibold">巡检报告调度配置</h3>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* 全局开关 */}
<div className="flex items-center justify-between rounded-md border border-border/40 px-3 py-2">
<div>
<div className="text-sm font-medium">自动调度</div>
<div className="text-xs text-muted-foreground">按周期自动巡检</div>
</div>
<Switch checked={local.enabled}
onCheckedChange={(v) => { setLocal(p => ({ ...p, enabled: v })); onToggleScheduler(v); }} />
</div>
{/* 巡检周期 */}
<div className="rounded-md border border-border/40 px-3 py-2">
<div className="mb-1 text-sm font-medium">默认巡检周期</div>
<div className="flex items-center gap-2">
<input type="number" min={1} max={72} step={1} className="w-20 rounded border border-border/60 bg-background px-2 py-1 text-sm"
value={local.defaultIntervalHours}
onChange={(e) => setLocal(p => ({ ...p, defaultIntervalHours: Number(e.target.value) || 1 }))} />
<span className="text-xs text-muted-foreground">小时</span>
</div>
</div>
{/* 日报告时间 */}
<div className="rounded-md border border-border/40 px-3 py-2">
<div className="mb-1 text-sm font-medium">日报告触发时间</div>
<input type="time" className="w-full rounded border border-border/60 bg-background px-2 py-1 text-sm"
value={local.dailyReportTime}
onChange={(e) => setLocal(p => ({ ...p, dailyReportTime: e.target.value }))} />
</div>
{/* AI 汇总 */}
<div className="flex items-center justify-between rounded-md border border-border/40 px-3 py-2">
<div>
<div className="text-sm font-medium">AI 汇总分析</div>
<div className="text-xs text-muted-foreground">手动触发快照时生成 AI 四段式汇总</div>
</div>
<Switch checked={local.aiSummaryEnabled}
onCheckedChange={(v) => setLocal(p => ({ ...p, aiSummaryEnabled: v }))} />
</div>
</div>
{/* 设备选择 */}
<div className="rounded-md border border-border/40">
<div className="flex items-center justify-between border-b border-border/30 px-3 py-2">
<div className="text-sm font-medium">
巡检设备选择
<span className="ml-2 text-xs font-normal text-muted-foreground">
已选 {enabledCount}/{allHosts.length} 台{selectedAll ? "(空 = 全部)" : ""}
</span>
</div>
<div className="flex gap-2 text-xs">
<button type="button" onClick={() => toggleAllDevices(true)}
className="rounded px-2 py-0.5 text-blue-500 hover:bg-blue-500/10">全选</button>
<button type="button" onClick={() => toggleAllDevices(false)}
className="rounded px-2 py-0.5 text-muted-foreground hover:bg-muted/50">全不选</button>
</div>
</div>
<div className="grid max-h-48 grid-cols-2 gap-x-4 gap-y-1 overflow-y-auto p-3 sm:grid-cols-3 lg:grid-cols-4">
{allHosts.length === 0 ? (
<div className="col-span-full py-4 text-center text-xs text-muted-foreground">
Vault 里还没设备,请先去 Vault 添加主机
</div>
) : allHosts.map(h => {
const checked = selectedAll ? true : (local.enabledDeviceIds?.includes(h.id) ?? false);
return (
<label key={h.id} className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted/40">
<input type="checkbox" className="h-3.5 w-3.5 rounded border-border"
checked={!!checked}
onChange={() => toggleDevice(h.id)} />
<span className="truncate text-xs font-mono">{h.label} <span className="text-muted-foreground">({h.hostname})</span></span>
{h.catalog_id && (
<span className="shrink-0 rounded border border-border/40 px-1 text-[9px] text-muted-foreground">
{CATALOG_LABEL[h.catalog_id] || h.catalog_id}
</span>
)}
</label>
);
})}
</div>
</div>
<div className="flex justify-end gap-2">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} 保存配置
</Button>
</div>
</div>
);
}