Files
SenMeshNet/electron/bridges/inspection/reportExport.cjs

1235 lines
52 KiB
JavaScript
Raw Normal View History

"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 };
},
};