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
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立 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 运行时依赖。
1375 lines
58 KiB
TypeScript
1375 lines
58 KiB
TypeScript
"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>
|
||
);
|
||
}
|