Files

1407 lines
60 KiB
TypeScript
Raw Permalink Normal View History

"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 渲染设备多选)
*
* 远程浏览器模式下:从 HTTP /api/vault/hosts 读 hosts(已脱敏),
* 从 /api/devices 读 catalog_id。targets 不带真实凭据(远程跑不了 SSH)。
*/
async function buildAllDeviceTargets(enabledDeviceIds?: string[]): Promise<{
targets: ReportDeviceTarget[];
allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }>;
}> {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || "";
const targets: ReportDeviceTarget[] = [];
const allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }> = [];
let hosts: any[] = [];
let extensions: Record<string, any> = {};
if (IS_REMOTE) {
// ── 远程浏览器:从 HTTP API 读 ──
try {
const url = `/api/vault/hosts?token=${encodeURIComponent(REMOTE_TOKEN)}`;
const res = await fetch(url);
const data = await res.json();
hosts = Array.isArray(data?.data) ? data.data : [];
} catch (_) {}
// catalog_id 从 /api/devices 读
try {
const url = `/api/devices?token=${encodeURIComponent(REMOTE_TOKEN)}`;
const res = await fetch(url);
const data = await res.json();
for (const d of (data?.devices || [])) {
if (d?.host_id) extensions[d.host_id] = { catalog_id: d.catalog_id || d.catalog_type };
}
} catch (_) {}
} else {
// ── Electron 本地:从 localStorage 读 ──
try {
const raw = localStorage.getItem(STORAGE_KEY_HOSTS);
if (raw) hosts = await decryptHosts(JSON.parse(raw));
} catch (_) {}
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 || host.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;
targets.push({
device_id: host.id,
hostname: host.hostname,
port: host.port || 22,
username: host.username || "",
password: host.password,
privateKeyContent: host.privateKeyContent || undefined,
passphrase: host.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);
// dirty 保护:用户在面板里编辑过 → 不要被外部 props.schedule 变化静默覆盖
// (比如主进程广播 schedule 变更、其他组件触发 loadSchedule 等)
const dirtyRef = useRef(false);
useEffect(() => {
if (!dirtyRef.current) setLocal(schedule);
}, [schedule]);
const handleSave = async () => {
setSaving(true);
await onSave(local);
dirtyRef.current = false;
setSaving(false);
};
/** 编辑点统一入口:改 local + 标记 dirty,防止外部 props 覆盖未保存的本地编辑 */
const edit = useCallback((updater: ReportSchedule | ((p: ReportSchedule) => ReportSchedule)) => {
dirtyRef.current = true;
setLocal(updater);
}, []);
// 设备多选 helpers
// enabledDeviceIds 约定:null/undefined = 全部,[] = 一个都不选,[id1,id2] = 指定
const selectedAll = !local.enabledDeviceIds; // null / undefined → 全部
const toggleAllDevices = (selectAll: boolean) => {
edit(p => ({ ...p, enabledDeviceIds: selectAll ? null : [] }));
};
const toggleDevice = (deviceId: string) => {
edit(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) => {
edit(p => ({ ...p, enabled: v }));
// onToggleScheduler 直接写主进程,成功后清 dirty(async)
Promise.resolve(onToggleScheduler(v)).then(() => { dirtyRef.current = false; });
}} />
</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) => edit(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) => edit(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) => edit(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>
);
}