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
Star History / update (push) Has been cancelled
调整 electron-builder 配置,Windows 平台仅打包 zip 格式,注释掉 NSIS 和 portable 目标; 注释掉 Windows Hello helper 资源配置以避免路径不存在导致的打包失败; 将发布配置设为 null 以关闭 GitHub 发布请求; 修改 afterPack 脚本,跳过 Windows 下 node-pty ConPTY 补丁的重建和复制,因内网无 MSVC 工具链无法完成该操作。
1235 lines
52 KiB
JavaScript
1235 lines
52 KiB
JavaScript
"use strict";
|
||
|
||
/**
|
||
* 巡检报告 PDF 导出(正式文档版)
|
||
*
|
||
* 复用 Electron 原生 BrowserWindow + webContents.printToPDF,
|
||
* 不引入额外 npm 依赖。A4 纸张、支持黑白打印、无 emoji。
|
||
*
|
||
* 两个模板:
|
||
* - renderSnapshotHtml(snap) → 单快照巡检报告(7 章节 + 健康评分)
|
||
* - renderDailyReportHtml(report) → 日运行聚合报告(6 章节 + 健康评分)
|
||
*
|
||
* 独立存储路径:app.getPath('userData')/inspection-reports/{YYYY-MM-DD}/
|
||
*/
|
||
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
|
||
// ─── 延迟 require ─────────────────────────────────────
|
||
function _getElectron() {
|
||
try { return require("electron"); }
|
||
catch (e) { throw new Error("reportExport 只能在 Electron 主进程中使用"); }
|
||
}
|
||
|
||
async function _printHtmlToPdf(html) {
|
||
const { BrowserWindow } = _getElectron();
|
||
const win = new BrowserWindow({
|
||
show: false,
|
||
webPreferences: { backgroundThrottling: false },
|
||
});
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const ok = win.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||
if (!ok) return reject(new Error("loadURL 返回 false"));
|
||
win.webContents.once("did-finish-load", resolve);
|
||
win.webContents.once("did-fail-load", (_e, code, msg) => reject(new Error(`did-fail-load ${code}: ${msg}`)));
|
||
setTimeout(() => reject(new Error("print loadURL 超时")), 15000);
|
||
});
|
||
return await win.webContents.printToPDF({
|
||
landscape: false,
|
||
printBackground: true,
|
||
marginType: "default",
|
||
pageSize: "A4",
|
||
});
|
||
} finally {
|
||
if (!win.isDestroyed()) win.destroy();
|
||
}
|
||
}
|
||
|
||
function _ensureDir(app) {
|
||
const dir = path.join(app.getPath("userData"), "inspection-reports");
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
function _dateDir(app, ts) {
|
||
const d = new Date(ts);
|
||
const pad = (n) => n.toString().padStart(2, "0");
|
||
const name = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
const base = _ensureDir(app);
|
||
const dir = path.join(base, name);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
// ─── 通用 helper ──────────────────────────────────────
|
||
|
||
const DEFAULT_SYSTEM_NAME = "NetMesh 智能巡检系统";
|
||
const PADDING = (n) => n.toString().padStart(2, "0");
|
||
|
||
function _fmtDateTime(ts) {
|
||
const d = new Date(ts);
|
||
return `${d.getFullYear()}-${PADDING(d.getMonth() + 1)}-${PADDING(d.getDate())} ${PADDING(d.getHours())}:${PADDING(d.getMinutes())}:${PADDING(d.getSeconds())}`;
|
||
}
|
||
|
||
function _fmtDate(ts) {
|
||
const d = new Date(ts);
|
||
return `${d.getFullYear()}-${PADDING(d.getMonth() + 1)}-${PADDING(d.getDate())}`;
|
||
}
|
||
|
||
/** severity 枚举 → 扣分 + 风险等级文字标签 */
|
||
const SEVERITY_MAP = {
|
||
critical: { deduct: 12, label: "【紧急】★★★★★", level: "紧急" },
|
||
warning: { deduct: 6, label: "【高优】★★★★☆", level: "高优" },
|
||
info: { deduct: 3, label: "【中优】★★★☆☆", level: "中优" },
|
||
low: { deduct: 1, label: "【低优】★★☆☆☆", level: "低优" },
|
||
};
|
||
|
||
function _severityInfo(sev) {
|
||
return SEVERITY_MAP[sev] || SEVERITY_MAP.info;
|
||
}
|
||
|
||
function _severityLabel(sev) { return _severityInfo(sev).label; }
|
||
|
||
/** 健康分数 → 等级文本 */
|
||
function _healthLevelText(score) {
|
||
if (score >= 90) return "[优秀]";
|
||
if (score >= 80) return "[健康]";
|
||
if (score >= 70) return "[良好]";
|
||
if (score >= 60) return "[一般]";
|
||
if (score >= 40) return "[较差]";
|
||
return "[严重]";
|
||
}
|
||
|
||
function _severityColor(sev) {
|
||
if (sev === "critical") return "#b91c1c";
|
||
if (sev === "warning") return "#d97706";
|
||
if (sev === "info") return "#2563eb";
|
||
return "#6b7280";
|
||
}
|
||
|
||
// ─── 健康评分算法 ────────────────────────────────────
|
||
|
||
/** 单次巡检快照的健康评分(满分 100,最低 0) */
|
||
// ─── 健康评分算法(多维度加权) ─────────────────────
|
||
// 满分 100,四维度加权:
|
||
// Reachability 40% 设备可及性 —— 在线设备占比
|
||
// Coverage 25% 巡检覆盖率 —— 命令成功率(仅在线设备)
|
||
// Risk 25% 隐患评级 —— AI hidden_risks 加权,上限 20
|
||
// Efficiency 10% 执行效率 —— 超时/时长异常
|
||
//
|
||
// 设计思路:
|
||
// - 比例扣分而非线性叠加:设备越多,单台离线扣分越小
|
||
// - 离线设备不参与覆盖率计算(本来就连不上)
|
||
// - Risk 维度有上限,防止 AI 隐患把总分打崩
|
||
// - Efficiency 权重偏低,因耗时不直接反映健康度
|
||
|
||
function calcSnapshotHealthScore(snap, aiSummary) {
|
||
const devices = snap?.devices || [];
|
||
const total = devices.length;
|
||
if (total === 0) {
|
||
return { score: 0, levelText: _healthLevelText(0), breakdown: "无设备数据" };
|
||
}
|
||
|
||
// ── 维度 1: 设备可及性(满分 40)─────────────────
|
||
const online = devices.filter(d => d.ok).length;
|
||
const offline = total - online;
|
||
const reachabilityScore = Math.round(40 * (online / total));
|
||
|
||
// ── 维度 2: 巡检覆盖率(满分 25)─────────────────
|
||
let totalCmds = 0, okCmds = 0;
|
||
for (const d of devices) {
|
||
if (!d.ok) continue; // 离线跳过
|
||
const cmds = d.commands || [];
|
||
for (const c of cmds) {
|
||
totalCmds++;
|
||
// command not found / 未识别 是框架 fallback,不算失败
|
||
if (!c.ok && /command not found|未识别|not recognized/i.test(c.stderr || c.stdout || "")) continue;
|
||
if (c.ok) okCmds++;
|
||
}
|
||
}
|
||
const coverageRatio = totalCmds === 0 ? 1 : okCmds / totalCmds;
|
||
const coverageScore = Math.round(25 * coverageRatio);
|
||
|
||
// ── 维度 3: 隐患评级(满分 25,上限扣 20)───────
|
||
const risks = aiSummary?.hidden_risks || [];
|
||
let riskPenalty = 0, riskCritical = 0, riskWarning = 0, riskInfo = 0;
|
||
for (const r of risks) {
|
||
if (!r || !r.severity) continue;
|
||
const sev = r.severity.toLowerCase();
|
||
if (sev === "critical") { riskPenalty += 5; riskCritical++; }
|
||
else if (sev === "warning" || sev === "high") { riskPenalty += 3; riskWarning++; }
|
||
else { riskPenalty += 1; riskInfo++; }
|
||
}
|
||
riskPenalty = Math.min(riskPenalty, 20); // 最多扣到只剩 5 分
|
||
const riskScore = 25 - riskPenalty;
|
||
|
||
// ── 维度 4: 执行效率(满分 10)───────────────────
|
||
let efficiencyScore = 10;
|
||
const durationMs = snap?.duration_ms || 0;
|
||
const avgMsPerDevice = total > 0 ? durationMs / total : 0;
|
||
if (avgMsPerDevice > 15000) {
|
||
const penalty = Math.min(6, Math.floor((avgMsPerDevice - 15000) / 10000));
|
||
efficiencyScore = 10 - penalty;
|
||
}
|
||
const timeoutDevices = devices.filter(d =>
|
||
d.ok && (d.commands || []).some(c => !c.ok && /ETIMEDOUT|timeout/i.test(c.stderr || c.stdout || ""))
|
||
).length;
|
||
if (timeoutDevices > 0) {
|
||
efficiencyScore = Math.max(4, efficiencyScore - Math.min(3, timeoutDevices));
|
||
}
|
||
|
||
// ── 最终得分(四维度求和)───────────────────────
|
||
const score = Math.max(0, reachabilityScore + coverageScore + riskScore + efficiencyScore);
|
||
const breakdown = [
|
||
`设备可及性: ${reachabilityScore}/40(在线 ${online}/${total})`,
|
||
`巡检覆盖率: ${coverageScore}/25(命令成功率 ${(coverageRatio * 100).toFixed(1)}%)`,
|
||
`隐患评级: ${riskScore}/25(隐患 ${risks.length} 条: 严重 ${riskCritical}, 警告 ${riskWarning}, 提示 ${riskInfo})`,
|
||
`执行效率: ${efficiencyScore}/10(平均 ${(avgMsPerDevice / 1000).toFixed(1)}s/台${timeoutDevices > 0 ? `, ${timeoutDevices} 台超时` : ""})`,
|
||
].join("\n");
|
||
|
||
return { score, levelText: _healthLevelText(score), breakdown };
|
||
}
|
||
|
||
/** 日报告健康评分(基于每个快照的分数,取算术平均 + max/min) */
|
||
function calcDailyHealthScore(snapScores) {
|
||
if (!snapScores || snapScores.length === 0) {
|
||
return { score: 0, levelText: _healthLevelText(0), max: 0, min: 0 };
|
||
}
|
||
const sum = snapScores.reduce((a, b) => a + b, 0);
|
||
const score = Math.round(sum / snapScores.length);
|
||
return {
|
||
score,
|
||
levelText: _healthLevelText(score),
|
||
max: Math.max(...snapScores),
|
||
min: Math.min(...snapScores),
|
||
};
|
||
}
|
||
|
||
// ─── 报告编号 ────────────────────────────────────────
|
||
|
||
function _snapReportId(snap) {
|
||
// INS-SNAP-YYYYMMDD-xxx(用快照 timestamp 生成日期,序号用 snap id 末尾数字)
|
||
const d = new Date(snap.timestamp);
|
||
const datePart = `${d.getFullYear()}${PADDING(d.getMonth() + 1)}${PADDING(d.getDate())}`;
|
||
// 从 snap.id 提取一个稳定序号,如 snap_xxx_0matlx → 取数字或字母序列
|
||
const seq = (snap.id.match(/(\d+)/) || [, "001"])[1].slice(-3).padStart(3, "0");
|
||
return `INS-SNAP-${datePart}-${seq}`;
|
||
}
|
||
|
||
function _dailyReportId(report) {
|
||
return `INS-DAILY-${(report.date || "").replace(/-/g, "")}-${report.id?.slice(-3) || "001"}`;
|
||
}
|
||
|
||
// ─── 正式 CSS(A4 打印、无 emoji 依赖) ────────────────
|
||
|
||
const FORMAL_CSS = `
|
||
@page {
|
||
margin: 2.54cm 2.5cm;
|
||
@top-center {
|
||
content: string(docHeader);
|
||
font-size: 10px;
|
||
color: #6b7280;
|
||
}
|
||
@bottom-center {
|
||
content: "第 " counter(page) " 页 / 共 " counter(pages) " 页";
|
||
font-size: 10px;
|
||
color: #6b7280;
|
||
}
|
||
}
|
||
html, body {
|
||
font-family: "SimSun", "宋体", "Microsoft YaHei", sans-serif;
|
||
font-size: 12px;
|
||
color: #1f2937;
|
||
line-height: 1.55;
|
||
}
|
||
h1 {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
text-align: center;
|
||
margin: 0 0 16px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 2px solid #374151;
|
||
string-set: docHeader content();
|
||
}
|
||
h2 {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
margin: 22px 0 10px;
|
||
padding-left: 10px;
|
||
border-left: 4px solid #374151;
|
||
color: #111827;
|
||
}
|
||
h3 {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
margin: 14px 0 6px;
|
||
color: #1f2937;
|
||
}
|
||
.report-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 24px;
|
||
font-size: 12px;
|
||
color: #374151;
|
||
margin-bottom: 14px;
|
||
padding: 10px 14px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 4px;
|
||
background: #f9fafb;
|
||
}
|
||
.report-meta .meta-item { white-space: nowrap; }
|
||
.report-meta .meta-label { color: #6b7280; margin-right: 4px; }
|
||
|
||
/* 健康评分块 */
|
||
.score-block {
|
||
border: 2px solid #1f2937;
|
||
border-radius: 6px;
|
||
padding: 14px 18px;
|
||
margin: 16px 0;
|
||
background: #fafafa;
|
||
page-break-inside: avoid;
|
||
}
|
||
.score-block h2 { margin-top: 0; border-left-color: #1f2937; }
|
||
.score-table { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
||
.score-table td {
|
||
border: 1px solid #d1d5db;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
}
|
||
.score-table td:first-child {
|
||
color: #6b7280;
|
||
background: #f3f4f6;
|
||
width: 30%;
|
||
font-weight: 500;
|
||
}
|
||
.score-table td.score-num {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
table { border-collapse: collapse; width: 100%; margin: 6px 0; font-size: 11.5px; }
|
||
th, td {
|
||
border: 1px solid #9ca3af;
|
||
padding: 5px 8px;
|
||
text-align: left;
|
||
vertical-align: top;
|
||
line-height: 1.45;
|
||
}
|
||
th {
|
||
background: #e5e7eb;
|
||
font-weight: 700;
|
||
font-size: 11.5px;
|
||
color: #111827;
|
||
}
|
||
tr:nth-child(even) td { background: #fafafa; }
|
||
tr:hover td { background: #f3f4f6; }
|
||
|
||
/* 风险等级文字标签 */
|
||
.sev-critical { color: #b91c1c; font-weight: 700; }
|
||
.sev-warning { color: #d97706; font-weight: 600; }
|
||
.sev-info { color: #2563eb; font-weight: 600; }
|
||
.sev-low { color: #6b7280; }
|
||
|
||
/* 表格内状态列小标签 */
|
||
.tag {
|
||
display: inline-block;
|
||
padding: 1px 6px;
|
||
border-radius: 2px;
|
||
font-size: 11px;
|
||
border: 1px solid currentColor;
|
||
}
|
||
.tag-red { color: #b91c1c; border-color: #b91c1c; }
|
||
.tag-amber { color: #b45309; border-color: #b45309; }
|
||
.tag-blue { color: #1d4ed8; border-color: #1d4ed8; }
|
||
.tag-green { color: #15803d; border-color: #15803d; }
|
||
.tag-gray { color: #6b7280; border-color: #6b7280; }
|
||
|
||
.cmd-fail-block {
|
||
background: #fef2f2;
|
||
border: 1px solid #fecaca;
|
||
border-radius: 4px;
|
||
padding: 8px 12px;
|
||
margin: 8px 0;
|
||
font-size: 11px;
|
||
page-break-inside: avoid;
|
||
}
|
||
.cmd-fail-block .cmd-name {
|
||
font-family: Consolas, "Courier New", monospace;
|
||
color: #b91c1c;
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
}
|
||
.cmd-fail-block .cmd-output {
|
||
background: #1f2937;
|
||
color: #e5e7eb;
|
||
padding: 6px 10px;
|
||
border-radius: 3px;
|
||
font-family: Consolas, "Courier New", monospace;
|
||
font-size: 10.5px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
max-height: 180px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.section-break { page-break-before: always; }
|
||
.no-print { display: none; }
|
||
|
||
/* 运维处置建议 */
|
||
.rec-block {
|
||
margin: 6px 0 12px;
|
||
padding: 8px 12px;
|
||
border-left: 3px solid #374151;
|
||
background: #f9fafb;
|
||
}
|
||
.rec-block h4 {
|
||
margin: 0 0 6px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.rec-block ol {
|
||
margin: 0;
|
||
padding-left: 20px;
|
||
font-size: 11.5px;
|
||
line-height: 1.75;
|
||
}
|
||
|
||
/* 目录 */
|
||
.toc {
|
||
border: 1px solid #d1d5db;
|
||
background: #fafafa;
|
||
border-radius: 4px;
|
||
padding: 12px 20px;
|
||
margin: 16px 0 18px;
|
||
}
|
||
.toc ol { margin: 0; padding-left: 20px; line-height: 1.9; font-size: 12px; }
|
||
|
||
/* 离线设备标记 */
|
||
.offline-device { color: #b91c1c; font-weight: 600; }
|
||
`;
|
||
|
||
// ─── 快照模板 ────────────────────────────────────────
|
||
|
||
function renderSnapshotHtml(snap, aiSummary, systemName) {
|
||
const _sys = systemName || DEFAULT_SYSTEM_NAME;
|
||
const scoreInfo = calcSnapshotHealthScore(snap, aiSummary);
|
||
|
||
// 快照报告总结拆成 3~4 条 bullet
|
||
const snapshotSummaryBullets = (() => {
|
||
const bullets = [];
|
||
const totalDevs = snap.devices.length;
|
||
const offlineDevs = snap.devices.filter(d => !d.ok).length;
|
||
const onlineDevs = totalDevs - offlineDevs;
|
||
const totalCmds = snap.devices.reduce((a, d) => a + d.commands.length, 0);
|
||
const failCmds = snap.devices.reduce((a, d) => a + d.commands.filter(c => !c.ok).length, 0);
|
||
bullets.push(`本次快照共巡检 ${totalDevs} 台设备,在线 ${onlineDevs} 台,离线 ${offlineDevs} 台;命令总执行 ${totalCmds} 条,失败 ${failCmds} 条`);
|
||
|
||
// 从 hidden_risks 聚合 severity
|
||
const severityCount = (aiSummary?.hidden_risks || []).reduce((a, r) => {
|
||
const s = r.severity || "info";
|
||
a[s] = (a[s] || 0) + 1; return a;
|
||
}, {});
|
||
const offlineCnt = (aiSummary?.offline_devices || []).length;
|
||
const riskParts = [];
|
||
if (offlineCnt) riskParts.push(`离线设备 ${offlineCnt} 台`);
|
||
if (severityCount.critical) riskParts.push(`紧急风险 ${severityCount.critical} 起`);
|
||
if (severityCount.warning) riskParts.push(`高优风险 ${severityCount.warning} 起`);
|
||
if (severityCount.info) riskParts.push(`中低优风险 ${(severityCount.info || 0) + (severityCount.low || 0)} 起`);
|
||
bullets.push(riskParts.length > 0 ? `风险统计:${riskParts.join(";")}` : "本次快照未检测到风险隐患");
|
||
|
||
// 从 AI device_status 里找异常设备
|
||
const abnormal = (aiSummary?.device_status || []).filter(d => !d.running_ok).map(d => d.device);
|
||
if (abnormal.length > 0) {
|
||
bullets.push(`运行异常设备共 ${abnormal.length} 台:${abnormal.slice(0, 5).join("、")}${abnormal.length > 5 ? ` 等 ${abnormal.length} 台` : ""}`);
|
||
} else {
|
||
bullets.push("所有在线设备运行状态正常");
|
||
}
|
||
|
||
// 从 AI recommendations 里提炼 Top 建议
|
||
const recs = (aiSummary?.recommendations || []).slice(0, 2);
|
||
if (recs.length > 0) {
|
||
bullets.push(`首要处置建议:${recs.join(";")}`);
|
||
} else if (aiSummary?.health_summary) {
|
||
bullets.push(`${aiSummary.health_summary}`);
|
||
} else {
|
||
bullets.push("AI 汇总尚未完成,详细分析请稍候");
|
||
}
|
||
return bullets;
|
||
})();
|
||
|
||
const scoreHtml = `
|
||
<div class="score-block">
|
||
<h2>巡检健康综合评分</h2>
|
||
<table class="score-table">
|
||
<tr><td>综合得分</td><td class="score-num">${scoreInfo.score}</td></tr>
|
||
<tr><td>健康等级</td><td style="font-weight:700; font-size:14px">${scoreInfo.levelText}</td></tr>
|
||
</table>
|
||
<div style="margin-top:10px; padding-top:10px; border-top:1px dashed #d1d5db; font-size:12px; color:#374151; line-height:1.8">
|
||
<strong style="color:#111827; display:block; margin-bottom:4px">报告总结:</strong>
|
||
<ul style="margin:0; padding-left:18px; list-style:disc">
|
||
${snapshotSummaryBullets.map(b => `<li>${b}</li>`).join("")}
|
||
</ul>
|
||
</div>
|
||
</div>`;
|
||
|
||
const offlineDevices = snap.devices.filter(d => !d.ok);
|
||
const onlineDevices = snap.devices.filter(d => d.ok);
|
||
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);
|
||
const failCmds = totalCmds - okCmds;
|
||
const failRate = totalCmds > 0 ? ((failCmds / totalCmds) * 100).toFixed(1) : "0.0";
|
||
const tsStr = _fmtDateTime(snap.timestamp);
|
||
const dateStr = _fmtDate(snap.timestamp);
|
||
const execDur = ((snap.duration_ms || 0) / 1000).toFixed(1);
|
||
const reportId = _snapReportId(snap);
|
||
|
||
const metaHtml = `
|
||
<div class="report-meta">
|
||
<span class="meta-item"><span class="meta-label">报告编号:</span>${reportId}</span>
|
||
<span class="meta-item"><span class="meta-label">执行时间:</span>${tsStr}</span>
|
||
<span class="meta-item"><span class="meta-label">触发方式:</span>${snap.trigger === "scheduled" ? "定时触发" : "手动触发"}</span>
|
||
<span class="meta-item"><span class="meta-label">巡检耗时:</span>${execDur} s</span>
|
||
<span class="meta-item"><span class="meta-label">设备总数:</span>${snap.devices.length} 台</span>
|
||
<span class="meta-item"><span class="meta-label">在线设备:</span>${onlineDevices.length} 台</span>
|
||
<span class="meta-item"><span class="meta-label">离线设备:</span>${offlineDevices.length} 台</span>
|
||
<span class="meta-item"><span class="meta-label">命令总执行:</span>${totalCmds} 条</span>
|
||
<span class="meta-item"><span class="meta-label">命令成功率:</span>${okCmds}/${totalCmds} (${((okCmds / Math.max(totalCmds, 1)) * 100).toFixed(1)}%)</span>
|
||
<span class="meta-item"><span class="meta-label">编制系统:</span>${_sys}</span>
|
||
<span class="meta-item"><span class="meta-label">编制日期:</span>${dateStr}</span>
|
||
<span class="meta-item"><span class="meta-label">保密级别:</span>内部运维资料,请勿外传</span>
|
||
</div>`;
|
||
|
||
// ═══ 目录 ═══
|
||
const tocHtml = `
|
||
<div class="toc">
|
||
<strong>目录</strong>
|
||
<ol>
|
||
<li>巡检总览</li>
|
||
<li>离线设备清单</li>
|
||
<li>风险隐患汇总</li>
|
||
<li>设备运行概览</li>
|
||
<li>运维处置建议</li>
|
||
<li>异常设备详情</li>
|
||
<li>附录 — 本次巡检命令集合</li>
|
||
</ol>
|
||
</div>`;
|
||
|
||
// ─── 1. 巡检总览 ───
|
||
const overviewHtml = `
|
||
<h2>1. 巡检总览</h2>
|
||
<p>本次快照于 <strong>${tsStr}</strong> 触发,共对 <strong>${snap.devices.length}</strong> 台网络设备执行批量 SSH 巡检;
|
||
${onlineDevices.length} 台设备 SSH 登录成功,${offlineDevices.length} 台设备 SSH 会话建立失败标记为离线;
|
||
在线设备共执行命令 <strong>${totalCmds}</strong> 条,成功 <strong>${okCmds}</strong> 条,失败 <strong>${failCmds}</strong> 条,命令成功率 ${((okCmds / Math.max(totalCmds, 1)) * 100).toFixed(1)}%。</p>
|
||
${aiSummary?.health_summary ? `<p style="margin-top:10px"><strong>AI 健康摘要:</strong>${aiSummary.health_summary}</p>` : "<p style='color:#6b7280; font-size:11.5px'>本次快照尚未完成 AI 汇总分析</p>"}
|
||
<table>
|
||
<thead><tr><th>项目</th><th>数值</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>设备总数</td><td>${snap.devices.length} 台</td></tr>
|
||
<tr><td>在线 / 离线</td><td>${onlineDevices.length} / ${offlineDevices.length}</td></tr>
|
||
<tr><td>命令成功 / 失败</td><td>${okCmds} / ${failCmds}</td></tr>
|
||
<tr><td>命令失败率</td><td>${failRate}%</td></tr>
|
||
<tr><td>巡检总耗时</td><td>${execDur} 秒</td></tr>
|
||
</tbody>
|
||
</table>`;
|
||
|
||
// ─── 2. 离线设备清单 ───
|
||
const offlineHtml = (() => {
|
||
if (offlineDevices.length === 0) return `<h2>2. 离线设备清单</h2><p>本次巡检所有设备均在线。</p>`;
|
||
const rows = offlineDevices.map(d => {
|
||
// 离线设备:从 stderr/stdout 提取故障分类
|
||
const firstErr = (d.commands[0]?.stderr || d.commands[0]?.stdout || "").trim();
|
||
let category = "SSH 会话建立失败";
|
||
let desc = firstErr || "SSH 连接超时或认证失败";
|
||
if (/Timed out while waiting for handshake|ETIMEDOUT/i.test(firstErr)) {
|
||
category = "SSH 握手超时";
|
||
desc = "网络不可达或设备 SSH 服务未正常监听";
|
||
} else if (/Invalid identification string|protocol version mismatch/i.test(firstErr)) {
|
||
category = "SSH 协议协商失败";
|
||
desc = "SSH 版本、加密算法协商失败,核对设备 SSH 配置与固件版本";
|
||
} else if (/connection is closed by SSH server/i.test(firstErr)) {
|
||
category = "SSH 服务侧关闭连接";
|
||
desc = "会话被设备主动关闭,请检查设备 SSH 服务状态";
|
||
} else if (/authentication failed|permission denied/i.test(firstErr)) {
|
||
category = "SSH 认证失败";
|
||
desc = "用户名 / 密码 / 密钥不匹配";
|
||
}
|
||
return `<tr><td class="offline-device">${d.hostname}</td><td>${category}</td><td>${desc}</td></tr>`;
|
||
}).join("");
|
||
return `
|
||
<h2>2. 离线设备清单</h2>
|
||
<p style="color:#6b7280; font-size:11.5px">以下设备 SSH 会话建立失败,未采集任何设备内部数据;不再重复罗列单条命令失败记录。</p>
|
||
<table>
|
||
<thead><tr><th>设备</th><th>故障分类</th><th>故障描述</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
})();
|
||
|
||
// ─── 3. 风险隐患汇总 ───
|
||
const riskHtml = (() => {
|
||
const risks = aiSummary?.hidden_risks;
|
||
if (!risks || risks.length === 0) {
|
||
return `<h2>3. 风险隐患汇总</h2><p>本次巡检未检测到需要关注的风险隐患。</p>`;
|
||
}
|
||
// 按 severity 从高到低排序
|
||
const sevOrder = { critical: 0, warning: 1, info: 2, low: 3 };
|
||
const sorted = [...risks].sort((a, b) => (sevOrder[a.severity] ?? 99) - (sevOrder[b.severity] ?? 99));
|
||
const rows = sorted.map(r => `
|
||
<tr>
|
||
<td><span class="sev-${r.severity}">${_severityLabel(r.severity)}</span></td>
|
||
<td><code>${r.device}</code></td>
|
||
<td>${(r.observation || "").slice(0, 120)}</td>
|
||
</tr>`).join("");
|
||
return `
|
||
<h2>3. 风险隐患汇总</h2>
|
||
<p style="color:#6b7280; font-size:11.5px">按风险优先级从高到低排序</p>
|
||
<table>
|
||
<thead><tr><th style="width:22%">风险等级</th><th style="width:18%">设备</th><th>风险简述</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
})();
|
||
|
||
// ─── 4. 设备运行概览(仅在线设备) ───
|
||
const overviewDevicesHtml = (() => {
|
||
if (onlineDevices.length === 0) return `<h2>4. 设备运行概览</h2><p>本次巡检无在线设备。</p>`;
|
||
const statusFromAI = {};
|
||
for (const ds of (aiSummary?.device_status || [])) statusFromAI[ds.device] = ds;
|
||
|
||
const rows = onlineDevices.slice(0, 50).map(d => {
|
||
const ai = statusFromAI[d.hostname];
|
||
const failCmds = d.commands.filter(c => !c.ok).length;
|
||
const aiStatus = ai ? (ai.running_ok ? '<span class="tag tag-green">正常</span>' : '<span class="tag tag-red">存在风险</span>')
|
||
: (failCmds === 0 ? '<span class="tag tag-green">正常</span>' : '<span class="tag tag-amber">存在异常</span>');
|
||
const highlights = ai
|
||
? [...(ai.highlights || []), ...(ai.issues || [])].join(";")
|
||
: (failCmds > 0 ? `${failCmds} 条命令失败` : "全部命令成功");
|
||
return `
|
||
<tr>
|
||
<td><code>${d.hostname}</code></td>
|
||
<td>${d.catalog_id || "-"}</td>
|
||
<td>${aiStatus}</td>
|
||
<td style="font-size:11px; color:#374151">${highlights.slice(0, 160)}</td>
|
||
</tr>`;
|
||
}).join("");
|
||
return `
|
||
<h2>4. 设备运行概览</h2>
|
||
<p style="color:#6b7280; font-size:11.5px">仅展示 SSH 登录成功设备;"正常 / 存在风险" 依据 AI 智能分析判定,无 AI 汇总时依据命令执行结果。</p>
|
||
<table>
|
||
<thead><tr><th style="width:18%">设备</th><th style="width:18%">设备类型</th><th style="width:12%">综合状态</th><th>关键观察</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
${onlineDevices.length > 50 ? `<p style="color:#6b7280; font-size:11px">其余 ${onlineDevices.length - 50} 台在线设备省略展示</p>` : ""}`;
|
||
})();
|
||
|
||
// ─── 5. 运维处置建议 ───
|
||
const recHtml = (() => {
|
||
if (!aiSummary?.recommendations || aiSummary.recommendations.length === 0) {
|
||
return `<h2>5. 运维处置建议</h2><p>本次巡检未生成处置建议。</p>`;
|
||
}
|
||
// 把 recommendations 按严重程度分组(简单启发式:用关键词匹配)
|
||
const criticalRecs = [];
|
||
const highRecs = [];
|
||
const midRecs = [];
|
||
const lowRecs = [];
|
||
for (const r of aiSummary.recommendations) {
|
||
if (/(立即|紧急|尽快|critical|马上|重启|更换|故障|掉电|离线)/i.test(r)) criticalRecs.push(r);
|
||
else if (/(重要|尽快|排查|修复|异常|暴力|crash|panic)/i.test(r)) highRecs.push(r);
|
||
else if (/(建议|关注|定期|监控|持续|计划)/i.test(r)) lowRecs.push(r);
|
||
else midRecs.push(r);
|
||
}
|
||
const block = (title, sevClass, items) => items.length === 0 ? "" : `
|
||
<div class="rec-block">
|
||
<h4 class="${sevClass}">${title}</h4>
|
||
<ol>${items.map(i => `<li>${i}</li>`).join("")}</ol>
|
||
</div>`;
|
||
return `
|
||
<h2>5. 运维处置建议</h2>
|
||
${block("【紧急】★★★★★ 需要立即处置", "sev-critical", criticalRecs)}
|
||
${block("【高优】★★★★☆ 尽快处理", "sev-warning", highRecs)}
|
||
${block("【中优】★★★☆☆ 择机维护", "sev-info", midRecs)}
|
||
${block("【低优】★★☆☆☆ 持续关注", "sev-low", lowRecs)}`;
|
||
})();
|
||
|
||
// ─── 6. 异常设备详情 ───
|
||
const detailHtml = (() => {
|
||
const abnormal = onlineDevices.filter(d => d.commands.some(c => !c.ok));
|
||
if (abnormal.length === 0) {
|
||
return `<h2>6. 异常设备详情</h2><p>本次巡检在线设备所有命令执行成功,无异常。</p>`;
|
||
}
|
||
const deviceBlocks = abnormal.map(d => {
|
||
const failCmds = d.commands.filter(c => !c.ok);
|
||
const cmdTable = `
|
||
<table>
|
||
<thead><tr><th style="width:240px">命令</th><th style="width:160px">用途</th><th style="width:120px">异常说明</th></tr></thead>
|
||
<tbody>
|
||
${failCmds.map(c => `
|
||
<tr>
|
||
<td style="font-family:Consolas,monospace; font-size:10.5px">${c.cmd}</td>
|
||
<td>${c.purpose || "-"}</td>
|
||
<td>${(c.stderr || c.stdout || "命令执行失败").slice(0, 100)}</td>
|
||
</tr>`).join("")}
|
||
</tbody>
|
||
</table>`;
|
||
const failOutputs = failCmds.map(c => `
|
||
<div class="cmd-fail-block">
|
||
<div class="cmd-name">${c.cmd}${c.purpose ? " — " + c.purpose : ""}</div>
|
||
<div class="cmd-output">${(c.stdout || c.stderr || "").slice(0, 2000)}</div>
|
||
</div>`).join("");
|
||
return `
|
||
<h3>设备:<code>${d.hostname}</code>(${d.catalog_id || "-"})</h3>
|
||
<p style="color:#6b7280; font-size:11px">命令成功 ${d.commands.filter(c => c.ok).length}/${d.commands.length},失败 ${failCmds.length} 条</p>
|
||
${cmdTable}
|
||
${failOutputs}
|
||
<div class="section-break"></div>`;
|
||
}).join("");
|
||
return `
|
||
<h2>6. 异常设备详情</h2>
|
||
<p style="color:#6b7280; font-size:11.5px">仅展示 SSH 登录成功、但部分巡检命令执行异常的设备;离线设备已在第 2 节列出、不再重复。</p>
|
||
${deviceBlocks}`;
|
||
})();
|
||
|
||
// ─── 7. 附录:巡检命令集合 ───
|
||
const appendixHtml = (() => {
|
||
const cmdSet = new Set();
|
||
for (const d of snap.devices) {
|
||
for (const c of d.commands) cmdSet.add(c.cmd);
|
||
}
|
||
const cmdList = Array.from(cmdSet);
|
||
const rows = cmdList.map((c, i) => `<tr><td style="width:30px">${i + 1}</td><td style="font-family:Consolas,monospace; font-size:11px">${c}</td></tr>`).join("");
|
||
return `
|
||
<h2>7. 附录 — 本次巡检命令集合</h2>
|
||
<table>
|
||
<thead><tr><th style="width:40px">#</th><th>命令</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
})();
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<title>网络设备巡检快照报告 ${reportId}</title>
|
||
<style>${FORMAL_CSS}</style>
|
||
</head>
|
||
<body>
|
||
<h1 style="font-size:28px; margin:0; padding-bottom:0; border-bottom:none; text-align:center; font-weight:600">${_sys}</h1>
|
||
<div style="height:14px"></div>
|
||
<div style="text-align:center; color:#1f2937; font-size:22px; font-weight:500; letter-spacing:0.5px; margin-bottom:18px">${tsStr} 网络设备巡检快照报告</div>
|
||
${scoreHtml}
|
||
${tocHtml}
|
||
${overviewHtml}
|
||
${offlineHtml}
|
||
${riskHtml}
|
||
${overviewDevicesHtml}
|
||
${recHtml}
|
||
${detailHtml}
|
||
${appendixHtml}
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// ─── 日运行报告模板 ──────────────────────────────────
|
||
|
||
function renderDailyReportHtml(report, allSnapshots, systemName) {
|
||
const _sys = systemName || DEFAULT_SYSTEM_NAME;
|
||
|
||
// 从快照构建 device_id → hostname 映射(用于把 UUID 替换成实际设备名)
|
||
const deviceIdToHost = new Map();
|
||
if (allSnapshots) {
|
||
for (const snap of allSnapshots) {
|
||
for (const d of snap.devices || []) {
|
||
if (d.device_id && d.hostname && !deviceIdToHost.has(d.device_id)) {
|
||
deviceIdToHost.set(d.device_id, d.hostname);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const _hostFor = (id) => deviceIdToHost.get(id) || id;
|
||
|
||
// 如果没传入快照列表就用 report 本身的 timeline/perSnapshotHighlights 计算
|
||
let snapScores = [];
|
||
if (allSnapshots && allSnapshots.length > 0) {
|
||
// 从快照列表计算每个快照的分数(日期匹配)
|
||
for (const snap of allSnapshots) {
|
||
const d = _fmtDate(snap.timestamp);
|
||
if (d === report.date) {
|
||
const ai = snap.ai_summary_status === "done" ? snap.ai_summary : null;
|
||
snapScores.push(calcSnapshotHealthScore(snap, ai).score);
|
||
}
|
||
}
|
||
}
|
||
const dailyScoreInfo = calcDailyHealthScore(snapScores);
|
||
|
||
const s = report.summary || {};
|
||
const tsStr = _fmtDateTime(report.timestamp);
|
||
const dailyReportId = _dailyReportId(report);
|
||
|
||
// ═══ 元信息 ═══
|
||
const metaHtml = `
|
||
<div class="report-meta">
|
||
<span class="meta-item"><span class="meta-label">报告编号:</span>${dailyReportId}</span>
|
||
<span class="meta-item"><span class="meta-label">报告日期:</span>${report.date}</span>
|
||
<span class="meta-item"><span class="meta-label">生成时间:</span>${tsStr}</span>
|
||
<span class="meta-item"><span class="meta-label">报告模式:</span>${report.mode === "finalized" ? "最终版" : " provisional 中间版"}</span>
|
||
<span class="meta-item"><span class="meta-label">快照次数:</span>${s.snapshotCount || 0} 次</span>
|
||
<span class="meta-item"><span class="meta-label">涉及设备:</span>${s.deviceCount || 0} 台</span>
|
||
<span class="meta-item"><span class="meta-label">总命令执行:</span>${s.totalCmds || 0} 条</span>
|
||
<span class="meta-item"><span class="meta-label">整体失败率:</span>${s.failRate ?? 0}%</span>
|
||
<span class="meta-item"><span class="meta-label">编制系统:</span>${_sys}</span>
|
||
<span class="meta-item"><span class="meta-label">保密级别:</span>内部运维资料</span>
|
||
</div>`;
|
||
|
||
// ═══ 健康评分块 ═══
|
||
// 日报告总结拆成 3~4 条 bullet
|
||
const dailySummaryBullets = (() => {
|
||
const snapCount = s.snapshotCount || 0;
|
||
const devCount = s.deviceCount || 0;
|
||
const totalCmds = s.totalCmds || 0;
|
||
const failCmds = s.failCmds || 0;
|
||
const failRate = s.failRate ?? 0;
|
||
|
||
// 从快照里聚合离线情况
|
||
let totalOffline = 0;
|
||
let maxOfflinePerSnap = 0;
|
||
for (const snap of (allSnapshots || [])) {
|
||
const off = (snap.devices || []).filter(d => !d.ok).length;
|
||
totalOffline += off;
|
||
if (off > maxOfflinePerSnap) maxOfflinePerSnap = off;
|
||
}
|
||
|
||
// timeline 事件聚合
|
||
const severityCount = (report.timeline || []).reduce((a, e) => {
|
||
a[e.severity || "info"] = (a[e.severity || "info"] || 0) + 1; return a;
|
||
}, {});
|
||
const totalRisk = severityCount.critical + severityCount.warning + severityCount.info + (severityCount.low || 0);
|
||
|
||
const bullets = [];
|
||
bullets.push(`本次共执行 ${snapCount} 次巡检快照,覆盖 ${devCount} 台设备,总命令执行 ${totalCmds} 条,命令成功率 ${snapCount === 0 ? 100 : Math.round((1 - failRate / 100) * 100)}%`);
|
||
bullets.push(`全部快照累计离线设备 ${totalOffline} 台次,单次最多离线 ${maxOfflinePerSnap} 台`);
|
||
bullets.push(`跨快照共检出风险事件 ${totalRisk} 起:紧急 ${severityCount.critical || 0} 起,高优 ${severityCount.warning || 0} 起,中优 ${(severityCount.info || 0) + (severityCount.low || 0)} 起`);
|
||
|
||
// 第四点:从 timeline 里提炼 TOP 风险设备
|
||
const topRisks = (report.timeline || [])
|
||
.filter(e => e.severity === "critical" || e.severity === "warning")
|
||
.reduce((m, e) => { m[e.device] = (m[e.device] || 0) + 1; return m; }, {});
|
||
const topDevs = Object.entries(topRisks)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 3)
|
||
.map(([d]) => d);
|
||
if (topDevs.length > 0) {
|
||
bullets.push(`需重点关注设备:${topDevs.join("、")},在当日巡检中反复出现高优先级问题`);
|
||
} else {
|
||
bullets.push("未检测到反复出现高优/紧急问题的设备,整体趋势平稳");
|
||
}
|
||
return bullets;
|
||
})();
|
||
|
||
const scoreHtml = `
|
||
<div class="score-block">
|
||
<h2>当日巡检健康综合评分</h2>
|
||
<table class="score-table">
|
||
<tr><td>当日综合得分</td><td class="score-num">${dailyScoreInfo.score}</td></tr>
|
||
<tr><td>健康等级</td><td style="font-weight:700">${dailyScoreInfo.levelText}</td></tr>
|
||
<tr><td>快照最高 / 最低</td><td>${dailyScoreInfo.max} / ${dailyScoreInfo.min}</td></tr>
|
||
</table>
|
||
<div style="margin-top:10px; padding-top:10px; border-top:1px dashed #d1d5db; font-size:12px; color:#374151; line-height:1.8">
|
||
<strong style="color:#111827; display:block; margin-bottom:4px">报告总结:</strong>
|
||
<ul style="margin:0; padding-left:18px; list-style:disc">
|
||
${dailySummaryBullets.map(b => `<li>${b}</li>`).join("")}
|
||
</ul>
|
||
</div>
|
||
</div>`;
|
||
|
||
// ═══ 目录 ═══
|
||
const tocHtml = `
|
||
<div class="toc">
|
||
<strong>目录</strong>
|
||
<ol>
|
||
<li>快照历史总览</li>
|
||
<li>故障事件时间线(聚合视图)</li>
|
||
<li>风险事件汇总</li>
|
||
<li>设备巡检成功率统计</li>
|
||
<li>命令失败 TOP 统计</li>
|
||
<li>综合运维结论与处置建议</li>
|
||
</ol>
|
||
</div>`;
|
||
|
||
// ─── 1. 快照历史总览 ───
|
||
const overviewHtml = (() => {
|
||
const highlights = report.perSnapshotHighlights || [];
|
||
if (highlights.length === 0 && snapScores.length === 0) {
|
||
return `<h2>1. 快照历史总览</h2><p>暂无快照数据。</p>`;
|
||
}
|
||
// 如果有真实快照分数(snapScores 数组),把每个快照的分数对齐到 time
|
||
const scoreMap = {};
|
||
if (allSnapshots) {
|
||
const perSnapScores = {};
|
||
for (const snap of allSnapshots) {
|
||
const ai = snap.ai_summary_status === "done" ? snap.ai_summary : null;
|
||
perSnapScores[snap.id] = calcSnapshotHealthScore(snap, ai).score;
|
||
}
|
||
// 用 timeline 或 perSnapshotHighlights 关联回快照
|
||
for (const h of highlights) {
|
||
if (h.snapshot_id) scoreMap[h.time] = perSnapScores[h.snapshot_id] ?? null;
|
||
}
|
||
}
|
||
// 还有 timeline 事件数可以补充到这张表里
|
||
const rows = highlights.length > 0 ? highlights.map(h => `
|
||
<tr>
|
||
<td><code>${h.time}</code></td>
|
||
<td>${h.health_summary ? h.health_summary.slice(0, 100) : "—"}</td>
|
||
<td style="text-align:center">${scoreMap[h.time] ?? "—"}</td>
|
||
</tr>`).join("") : `<tr><td colspan="3" style="text-align:center; color:#6b7280">暂无 AI 汇总快照</td></tr>`;
|
||
|
||
return `
|
||
<h2>1. 快照历史总览</h2>
|
||
<table>
|
||
<thead><tr><th style="width:140px">快照时间</th><th>AI 健康摘要</th><th style="width:90px">快照得分</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
})();
|
||
|
||
// ─── 2. 故障事件时间线(聚合视图) ───
|
||
const timelineHtml = (() => {
|
||
const timeline = report.timeline || [];
|
||
if (timeline.length === 0) {
|
||
return `<h2>2. 故障事件时间线</h2><p>当日无异常事件。</p>`;
|
||
}
|
||
// 聚合:同设备 + 同类型 → 合并,统计出现次数,保留首次和末次时间,汇总事件简述
|
||
const groups = {};
|
||
for (const ev of timeline) {
|
||
const key = `${ev.device}|${ev.type}`;
|
||
if (!groups[key]) {
|
||
groups[key] = {
|
||
...ev,
|
||
count: 1,
|
||
firstTime: ev.time,
|
||
lastTime: ev.time,
|
||
details: ev.detail ? [ev.detail] : [],
|
||
};
|
||
} else {
|
||
groups[key].count++;
|
||
groups[key].lastTime = ev.time;
|
||
if (ev.detail && !groups[key].details.includes(ev.detail)) {
|
||
groups[key].details.push(ev.detail);
|
||
}
|
||
// severity 升级:critical 覆盖 warning 覆盖 info
|
||
if (ev.severity === "critical") groups[key].severity = "critical";
|
||
else if (ev.severity === "warning" && groups[key].severity !== "critical") groups[key].severity = "warning";
|
||
}
|
||
}
|
||
const sorted = Object.values(groups).sort((a, b) => {
|
||
const order = { critical: 0, warning: 1, info: 2 };
|
||
return (order[a.severity] ?? 99) - (order[b.severity] ?? 99);
|
||
});
|
||
|
||
const rows = sorted.map(g => {
|
||
const sevLabel = _severityLabel(g.severity || "info");
|
||
const typeMap = { offline: "设备离线", risk: "风险隐患", anomaly: "异常" };
|
||
// 简述:取前 3 条不同 detail,超过则加"等";离线类直接用第一条 detail
|
||
const detailList = g.details && g.details.length > 0
|
||
? g.details.slice(0, 3).map((d, i) => `${i + 1}. ${d}`).join("<br/>")
|
||
+ (g.details.length > 3 ? `<br/>...(另有 ${g.details.length - 3} 条)` : "")
|
||
: (g.detail || "");
|
||
return `
|
||
<tr>
|
||
<td><span class="sev-${g.severity || "info"}">${sevLabel}</span></td>
|
||
<td><code>${g.device}</code></td>
|
||
<td>${typeMap[g.type] || g.type || "-"}</td>
|
||
<td style="text-align:center">${g.count} 次</td>
|
||
<td style="white-space:nowrap">${g.firstTime}</td>
|
||
<td style="white-space:nowrap">${g.lastTime}</td>
|
||
<td style="font-size:11.5px; line-height:1.6">${detailList}</td>
|
||
</tr>`;
|
||
}).join("");
|
||
|
||
return `
|
||
<h2>2. 故障事件时间线(聚合视图)</h2>
|
||
<p style="color:#6b7280; font-size:11.5px">同一设备同类事件做统计,保留首次、末次发生时间与发生次数,减少重复行。</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:22%">事件等级</th>
|
||
<th style="width:15%">设备</th>
|
||
<th style="width:12%">事件类型</th>
|
||
<th style="width:8%">次数</th>
|
||
<th style="width:10%">首次</th>
|
||
<th style="width:10%">末次</th>
|
||
<th>事件简述</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
})();
|
||
|
||
// ─── 3. 风险事件汇总(跨快照聚合) ───
|
||
const riskHtml = (() => {
|
||
if (!allSnapshots || allSnapshots.length === 0) {
|
||
// 没真实快照数据就用 timeline 事件
|
||
const timelineRisks = (report.timeline || []).filter(e => e.type === "risk" || e.type === "anomaly");
|
||
if (timelineRisks.length === 0) {
|
||
return `<h2>3. 风险事件汇总</h2><p>当日快照尚未完成 AI 分析,暂无风险汇总。</p>`;
|
||
}
|
||
const rows = timelineRisks.map(r => `
|
||
<tr>
|
||
<td><span class="sev-${r.severity || "info"}">${_severityLabel(r.severity || "info")}</span></td>
|
||
<td><code>${r.device}</code></td>
|
||
<td>${(r.detail || "").slice(0, 100)}</td>
|
||
</tr>`).join("");
|
||
return `
|
||
<h2>3. 风险事件汇总</h2>
|
||
<table>
|
||
<thead><tr><th style="width:22%">风险等级</th><th style="width:18%">设备</th><th>风险简述</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
}
|
||
// 真实快照数据:从所有快照的 hidden_risks 聚合(同设备+同 severity 合并)
|
||
const agg = new Map();
|
||
for (const snap of allSnapshots) {
|
||
if (snap.ai_summary_status !== "done") continue;
|
||
const risks = snap.ai_summary?.hidden_risks || [];
|
||
for (const r of risks) {
|
||
const key = `${r.device}|${r.severity || "info"}`;
|
||
const prev = agg.get(key);
|
||
if (!prev) {
|
||
agg.set(key, { device: r.device, severity: r.severity || "info", observations: [r.observation] });
|
||
} else {
|
||
if (!prev.observations.includes(r.observation)) prev.observations.push(r.observation);
|
||
}
|
||
}
|
||
}
|
||
if (agg.size === 0) return `<h2>3. 风险事件汇总</h2><p>当日未检测到需要关注的风险。</p>`;
|
||
const order = { critical: 0, warning: 1, info: 2, low: 3 };
|
||
const rows = [...agg.values()].sort((a, b) => (order[a.severity] ?? 99) - (order[b.severity] ?? 99)).map(r => `
|
||
<tr>
|
||
<td><span class="sev-${r.severity}">${_severityLabel(r.severity)}</span></td>
|
||
<td><code>${r.device}</code></td>
|
||
<td>${(r.observations || []).join(";").slice(0, 150)}</td>
|
||
</tr>`).join("");
|
||
return `
|
||
<h2>3. 风险事件汇总</h2>
|
||
<p style="color:#6b7280; font-size:11.5px">跨快照聚合当日全部巡检快照的 AI 分析结果,按优先级排序。</p>
|
||
<table>
|
||
<thead><tr><th style="width:22%">风险等级</th><th style="width:18%">设备</th><th>风险简述</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
})();
|
||
|
||
// ─── 4. 设备巡检成功率 ───
|
||
const deviceSuccessHtml = (() => {
|
||
const rate = s.deviceSuccessRate || {};
|
||
const entries = Object.entries(rate);
|
||
if (entries.length === 0) return `<h2>4. 设备巡检成功率统计</h2><p>暂无设备数据。</p>`;
|
||
const rows = entries.sort((a, b) => a[1] - b[1]).slice(0, 50).map(([id, r]) => {
|
||
const pct = (r * 100).toFixed(1);
|
||
const tag = r >= 0.98 ? '<span class="tag tag-green">在线-正常</span>'
|
||
: r >= 0.8 ? '<span class="tag tag-amber">在线-有风险</span>'
|
||
: r === 0 ? '<span class="tag tag-red">离线</span>'
|
||
: '<span class="tag tag-red">巡检异常</span>';
|
||
return `<tr><td><code>${_hostFor(id)}</code></td><td>${pct}%</td><td>${tag}</td></tr>`;
|
||
}).join("");
|
||
return `
|
||
<h2>4. 设备巡检成功率统计</h2>
|
||
<table>
|
||
<thead><tr><th style="width:25%">设备</th><th style="width:20%">巡检成功率</th><th>综合状态</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
})();
|
||
|
||
// ─── 5. 命令失败 TOP ───
|
||
const topFailHtml = (() => {
|
||
const fails = s.topFailedCommands || [];
|
||
if (fails.length === 0) return `<h2>5. 命令失败 TOP 统计</h2><p>当日无命令失败。</p>`;
|
||
const rows = fails.slice(0, 20).map((f, i) => `
|
||
<tr>
|
||
<td style="width:30px">${i + 1}</td>
|
||
<td style="font-family:Consolas,monospace; font-size:11px">${f.cmd}</td>
|
||
<td><code>${_hostFor(f.device_id)}</code></td>
|
||
<td style="text-align:center; width:70px">${f.count} 次</td>
|
||
</tr>`).join("");
|
||
return `
|
||
<h2>5. 命令失败 TOP 统计</h2>
|
||
<table>
|
||
<thead><tr><th style="width:30px">#</th><th>命令</th><th style="width:20%">涉及设备</th><th style="width:80px">失败次数</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
})();
|
||
|
||
// ─── 6. 处置建议 ───
|
||
const recHtml = (() => {
|
||
const block = (title, sevClass, items) => items.length === 0 ? "" : `
|
||
<div class="rec-block">
|
||
<h4 class="${sevClass}">${title}</h4>
|
||
<ol>${items.map(i => `<li>${i}</li>`).join("")}</ol>
|
||
</div>`;
|
||
|
||
// 优先用 AI 生成的 recommendations(如果有)
|
||
let recs = report.aiSummary?.recommendations;
|
||
|
||
// 没有 AI 汇总 → 从 timeline + 各快照程序化合成建议
|
||
if (!recs || recs.length === 0) {
|
||
recs = [];
|
||
// 1. timeline 里的 critical 事件 → 紧急建议
|
||
const criticalEvents = (report.timeline || []).filter(e => e.severity === "critical");
|
||
const criticalByDevice = new Map();
|
||
for (const e of criticalEvents) {
|
||
const prev = criticalByDevice.get(e.device) || [];
|
||
if (!prev.includes(e.type)) prev.push(e.type);
|
||
criticalByDevice.set(e.device, prev);
|
||
}
|
||
for (const [dev, types] of criticalByDevice) {
|
||
recs.push(`设备 ${dev} 发生 ${types.join("、")} 共 ${criticalEvents.filter(e => e.device === dev).length} 次,需立即排查处置`);
|
||
}
|
||
// 2. timeline 里的 warning 事件 → 高优建议
|
||
const warningEvents = (report.timeline || []).filter(e => e.severity === "warning");
|
||
const warnByDevice = new Map();
|
||
for (const e of warningEvents) {
|
||
const prev = warnByDevice.get(e.device) || [];
|
||
if (!prev.includes(e.type)) prev.push(e.type);
|
||
warnByDevice.set(e.device, prev);
|
||
}
|
||
for (const [dev, types] of warnByDevice) {
|
||
recs.push(`设备 ${dev} 存在 ${types.join("、")},建议尽快排查处理`);
|
||
}
|
||
// 3. 离线设备 → 处置建议
|
||
const offlineDevs = (report.timeline || []).filter(e => e.type === "offline");
|
||
const offlineHosts = [...new Set(offlineDevs.map(e => e.device))];
|
||
if (offlineHosts.length > 0) {
|
||
recs.push(`离线设备(${offlineHosts.length} 台):${offlineHosts.join("、")} — 检查网络连通性与设备供电,排查 SSH 服务状态`);
|
||
}
|
||
// 4. 设备成功率低于 80% → 持续关注
|
||
const lowRate = Object.entries(report.summary?.deviceSuccessRate || {})
|
||
.filter(([, v]) => v < 0.8)
|
||
.map(([dev]) => dev);
|
||
if (lowRate.length > 0) {
|
||
recs.push(`命令成功率偏低的设备(${lowRate.length} 台):${lowRate.join("、")} — 检查命令兼容性与设备固件版本`);
|
||
}
|
||
// 5. topFailedCommands 跨快照高频失败 → 建议排查
|
||
const topFails = (report.summary?.topFailedCommands || []).slice(0, 3);
|
||
if (topFails.length > 0) {
|
||
for (const f of topFails) {
|
||
recs.push(`命令 "${f.cmd}" 在 ${f.count} 次巡检中失败 — 确认设备固件是否支持该命令`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (recs.length === 0) {
|
||
return `<h2>6. 综合运维结论与处置建议</h2><p>当日系统运行正常,无需要处置的风险。</p>`;
|
||
}
|
||
|
||
// 分组
|
||
const criticalRecs = recs.filter(r => /立即|紧急|尽快|重启|更换|故障|掉电|critical|排查处置|离线设备/i.test(r));
|
||
const highRecs = recs.filter(r => /重要|尽快|排查|修复|异常|暴力|尽快排查/i.test(r));
|
||
const lowRecs = recs.filter(r => /建议|关注|定期|监控|计划|成功率偏低|命令.*失败/i.test(r));
|
||
const midRecs = recs.filter(r => !criticalRecs.includes(r) && !highRecs.includes(r) && !lowRecs.includes(r));
|
||
|
||
return `
|
||
<h2>6. 综合运维结论与处置建议</h2>
|
||
<p style="color:#6b7280; font-size:11.5px">按紧急、高优、中优、低优分组输出,供运维团队参考。</p>
|
||
${block("【紧急】★★★★★ 需要立即处置", "sev-critical", criticalRecs)}
|
||
${block("【高优】★★★★☆ 尽快处理", "sev-warning", highRecs)}
|
||
${block("【中优】★★★☆☆ 择机维护", "sev-info", midRecs)}
|
||
${block("【低优】★★☆☆☆ 持续关注", "sev-low", lowRecs)}`;
|
||
})();
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<title>网络设备日运行巡检报告 ${dailyReportId}</title>
|
||
<style>${FORMAL_CSS}</style>
|
||
</head>
|
||
<body>
|
||
<h1 style="font-size:28px; margin:0; padding-bottom:0; border-bottom:none; text-align:center; font-weight:600">${_sys}</h1>
|
||
<div style="height:14px"></div>
|
||
<div style="text-align:center; color:#1f2937; font-size:22px; font-weight:500; letter-spacing:0.5px; margin-bottom:18px">${_fmtDate(report.timestamp || Date.now())} 网络设备巡检日报告</div>
|
||
${scoreHtml}
|
||
${tocHtml}
|
||
${overviewHtml}
|
||
${timelineHtml}
|
||
${riskHtml}
|
||
${deviceSuccessHtml}
|
||
${topFailHtml}
|
||
${recHtml}
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
// ─── 主入口 ───────────────────────────────────────
|
||
|
||
async function exportSnapshotPdf(app, snapshot, aiSummary, customPath, systemName) {
|
||
const filePath = customPath || (() => {
|
||
const dir = _dateDir(app, snapshot.timestamp);
|
||
const fileName = `snap_${snapshot.id}_${Date.now()}.pdf`;
|
||
return path.join(dir, fileName);
|
||
})();
|
||
const html = renderSnapshotHtml(snapshot, aiSummary, systemName);
|
||
const pdf = await _printHtmlToPdf(html);
|
||
fs.writeFileSync(filePath, pdf);
|
||
console.log(`[reportExport] 快照 PDF: ${filePath}${aiSummary ? " (含AI汇总)" : ""}`);
|
||
return filePath;
|
||
}
|
||
|
||
async function exportDailyReportPdf(app, report, customPath, allSnapshots, systemName) {
|
||
const filePath = customPath || (() => {
|
||
const dir = _dateDir(app, report.timestamp || Date.now());
|
||
const fileName = `daily_${report.id}_${Date.now()}.pdf`;
|
||
return path.join(dir, fileName);
|
||
})();
|
||
const html = renderDailyReportHtml(report, allSnapshots, systemName);
|
||
const pdf = await _printHtmlToPdf(html);
|
||
fs.writeFileSync(filePath, pdf);
|
||
console.log(`[reportExport] 日报告 PDF: ${filePath}`);
|
||
return filePath;
|
||
}
|
||
|
||
module.exports = {
|
||
renderSnapshotHtml,
|
||
renderDailyReportHtml,
|
||
calcSnapshotHealthScore,
|
||
calcDailyHealthScore,
|
||
exportSnapshotPdf,
|
||
exportDailyReportPdf,
|
||
|
||
/** 内嵌 BrowserWindow 预览(temp 目录 + 自动清理) */
|
||
async previewSnapshotPdf(app, snapshot, aiSummary, systemName) {
|
||
const { BrowserWindow } = _getElectron();
|
||
const tmpDir = app.getPath("temp");
|
||
const filePath = path.join(tmpDir, `netcatty-preview-snap_${Date.now()}.pdf`);
|
||
const html = renderSnapshotHtml(snapshot, aiSummary, systemName);
|
||
const pdf = await _printHtmlToPdf(html);
|
||
fs.writeFileSync(filePath, pdf);
|
||
const win = new BrowserWindow({
|
||
width: 1100, height: 800, show: true, autoHideMenuBar: true,
|
||
title: `巡检快照预览 · ${_snapReportId(snapshot)}`,
|
||
});
|
||
win.setMenuBarVisibility(false);
|
||
const urlPath = filePath.replace(/\\/g, "/");
|
||
const url = `file://${urlPath.startsWith("/") ? "" : "/"}${urlPath}`;
|
||
win.loadURL(url);
|
||
win.once("closed", () => { try { fs.unlinkSync(filePath); } catch (_) {} });
|
||
return { ok: true, filePath };
|
||
},
|
||
|
||
async previewDailyReportPdf(app, report, allSnapshots, systemName) {
|
||
const { BrowserWindow } = _getElectron();
|
||
const tmpDir = app.getPath("temp");
|
||
const filePath = path.join(tmpDir, `netcatty-preview-daily_${Date.now()}.pdf`);
|
||
const html = renderDailyReportHtml(report, allSnapshots, systemName);
|
||
const pdf = await _printHtmlToPdf(html);
|
||
fs.writeFileSync(filePath, pdf);
|
||
const win = new BrowserWindow({
|
||
width: 1100, height: 800, show: true, autoHideMenuBar: true,
|
||
title: `日运行报告预览 · ${report.date}`,
|
||
});
|
||
win.setMenuBarVisibility(false);
|
||
const urlPath = filePath.replace(/\\/g, "/");
|
||
const url = `file://${urlPath.startsWith("/") ? "" : "/"}${urlPath}`;
|
||
win.loadURL(url);
|
||
win.once("closed", () => { try { fs.unlinkSync(filePath); } catch (_) {} });
|
||
return { ok: true, filePath };
|
||
},
|
||
};
|