Files
SenMeshNet/electron/bridges/inspection/reportExport.cjs
zhaolei 336c3afd47
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
build: 内网环境下跳过 Windows node-pty 补丁构建并关闭 GitHub 发布
调整 electron-builder 配置,Windows 平台仅打包 zip 格式,注释掉 NSIS 和 portable 目标;
注释掉 Windows Hello helper 资源配置以避免路径不存在导致的打包失败;
将发布配置设为 null 以关闭 GitHub 发布请求;
修改 afterPack 脚本,跳过 Windows 下 node-pty ConPTY 补丁的重建和复制,因内网无 MSVC 工具链无法完成该操作。
2026-10-15 15:30:31 +08:00

1235 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use 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 };
},
};