2026-10-19 21:11:57 +08:00
|
|
|
|
"use strict";
|
2026-10-15 13:53:04 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 日运行报告存储 + 聚合
|
|
|
|
|
|
*
|
|
|
|
|
|
* 每次生成日报告一条:
|
|
|
|
|
|
* {
|
|
|
|
|
|
* id, // "daily_<date>_<mode>"
|
|
|
|
|
|
* date, // "2026-10-13"
|
|
|
|
|
|
* mode, // "provisional" | "finalized"
|
|
|
|
|
|
* timestamp, // 生成时间
|
|
|
|
|
|
* trigger, // "manual" | "scheduled"
|
|
|
|
|
|
* period: { start_ts, end_ts }, // 覆盖的数据时间范围
|
|
|
|
|
|
* summary: {
|
|
|
|
|
|
* snapshotCount,
|
|
|
|
|
|
* deviceCount,
|
|
|
|
|
|
* totalCmds, okCmds, failCmds,
|
|
|
|
|
|
* failRate,
|
|
|
|
|
|
* deviceSuccessRate: { host_a: 0.98, ... },
|
|
|
|
|
|
* topFailedCommands: [ { cmd, device_id, count } ]
|
|
|
|
|
|
* },
|
|
|
|
|
|
* aiSummary: { ... } | null // AI 四段式汇总,可选
|
|
|
|
|
|
* }
|
|
|
|
|
|
*
|
|
|
|
|
|
* 独立文件,不与 snapshots 混存。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const { STORAGE_KEY_DAILY_REPORTS } = require("./storageKeys.cjs");
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_DAILY_REPORTS = 120; // 保留最近约 4 个月(每天 1 条)
|
|
|
|
|
|
|
|
|
|
|
|
function _safeParse(raw, def) {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
if (raw === null || raw === undefined) return def;
|
|
|
|
|
|
if (typeof raw === "object") return raw;
|
2026-10-15 13:53:04 +08:00
|
|
|
|
try { return JSON.parse(raw); }
|
|
|
|
|
|
catch { return def; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let _reports = null;
|
|
|
|
|
|
|
|
|
|
|
|
function _ensureLoaded(readFn) {
|
|
|
|
|
|
if (_reports === null) {
|
|
|
|
|
|
const raw = readFn(STORAGE_KEY_DAILY_REPORTS);
|
|
|
|
|
|
_reports = raw ? _safeParse(raw, []) : [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _save(writeFn) {
|
|
|
|
|
|
if (_reports.length > MAX_DAILY_REPORTS) {
|
|
|
|
|
|
_reports = _reports.slice(-MAX_DAILY_REPORTS);
|
|
|
|
|
|
}
|
|
|
|
|
|
writeFn(STORAGE_KEY_DAILY_REPORTS, JSON.stringify(_reports, null, 2));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _dayStart(date) {
|
|
|
|
|
|
const d = new Date(date);
|
|
|
|
|
|
d.setHours(0, 0, 0, 0);
|
|
|
|
|
|
return d.getTime();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _dayEnd(date) {
|
|
|
|
|
|
const d = new Date(date);
|
|
|
|
|
|
d.setHours(23, 59, 59, 999);
|
|
|
|
|
|
return d.getTime();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _dateStr(ts) {
|
|
|
|
|
|
const d = new Date(ts);
|
|
|
|
|
|
const p = (n) => n.toString().padStart(2, "0");
|
|
|
|
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从一组 snapshots 聚合出一份日运行报告数据
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {Array} snapshots - 快照数组
|
|
|
|
|
|
* @param {number} startTs - 数据时间范围起点
|
|
|
|
|
|
* @param {number} endTs - 数据时间范围终点
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildDailyReport(snapshots, startTs, endTs, { mode, trigger }) {
|
|
|
|
|
|
const filtered = snapshots.filter(s => s.timestamp >= startTs && s.timestamp <= endTs);
|
|
|
|
|
|
|
|
|
|
|
|
const deviceMap = new Map(); // hostId -> { okCmds, totalCmds, okCount, totalCount }
|
|
|
|
|
|
const cmdFailureMap = new Map(); // "host|cmd" -> count
|
|
|
|
|
|
let totalCmds = 0, okCmds = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (const snap of filtered) {
|
|
|
|
|
|
for (const dev of snap.devices) {
|
|
|
|
|
|
const prev = deviceMap.get(dev.device_id) || { okCmds: 0, totalCmds: 0 };
|
|
|
|
|
|
for (const cmd of dev.commands) {
|
|
|
|
|
|
totalCmds++;
|
|
|
|
|
|
if (cmd.ok) { okCmds++; prev.okCmds++; }
|
|
|
|
|
|
prev.totalCmds++;
|
|
|
|
|
|
if (!cmd.ok) {
|
|
|
|
|
|
const key = `${dev.device_id}|${cmd.cmd}`;
|
|
|
|
|
|
cmdFailureMap.set(key, (cmdFailureMap.get(key) || 0) + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
deviceMap.set(dev.device_id, prev);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 设备成功率
|
|
|
|
|
|
const deviceSuccessRate = {};
|
|
|
|
|
|
for (const [devId, v] of deviceMap) {
|
|
|
|
|
|
deviceSuccessRate[devId] = v.totalCmds > 0
|
|
|
|
|
|
? Number((v.okCmds / v.totalCmds).toFixed(3)) : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Top 失败命令(最多 20 条)
|
|
|
|
|
|
const topFailedCommands = Array.from(cmdFailureMap.entries())
|
|
|
|
|
|
.sort((a, b) => b[1] - a[1])
|
|
|
|
|
|
.slice(0, 20)
|
|
|
|
|
|
.map(([key, count]) => {
|
|
|
|
|
|
const [device_id, cmd] = key.split("|");
|
|
|
|
|
|
return { device_id, cmd, count };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const dateStr = _dateStr(endTs);
|
|
|
|
|
|
|
|
|
|
|
|
// ═══ 新增:从快照 AI 汇总构建故障时间线 ═══
|
|
|
|
|
|
// 只有 ai_summary_status === "done" 的快照才贡献 timeline 事件
|
|
|
|
|
|
const timeline = [];
|
|
|
|
|
|
const perSnapshotHighlights = []; // 每个快照的健康摘要(按时间序)
|
|
|
|
|
|
for (const snap of filtered) {
|
|
|
|
|
|
const ts = snap.timestamp;
|
|
|
|
|
|
const ai = snap.ai_summary;
|
|
|
|
|
|
const status = snap.ai_summary_status;
|
|
|
|
|
|
const timeStr = new Date(ts).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
|
|
|
|
const dateTimeStr = new Date(ts).toLocaleString("zh-CN", { hour: "2-digit", minute: "2-digit", month: "2-digit", day: "2-digit" });
|
|
|
|
|
|
|
|
|
|
|
|
if (ai && status === "done") {
|
|
|
|
|
|
// 每个快照的健康摘要(时间线串起来的基础)
|
|
|
|
|
|
perSnapshotHighlights.push({
|
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
|
time: timeStr,
|
|
|
|
|
|
snapshot_id: snap.id,
|
|
|
|
|
|
health_summary: ai.health_summary || "—",
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 离线设备 → timeline event
|
|
|
|
|
|
for (const dev of (ai.offline_devices || [])) {
|
|
|
|
|
|
timeline.push({
|
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
|
time: timeStr,
|
|
|
|
|
|
snapshot_id: snap.id,
|
|
|
|
|
|
type: "offline",
|
|
|
|
|
|
severity: "critical",
|
|
|
|
|
|
device: dev.device || dev.hostname || dev,
|
|
|
|
|
|
detail: dev.reason || "SSH 连接失败",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
// 隐藏风险 → timeline event
|
|
|
|
|
|
for (const risk of (ai.hidden_risks || [])) {
|
|
|
|
|
|
timeline.push({
|
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
|
time: timeStr,
|
|
|
|
|
|
snapshot_id: snap.id,
|
|
|
|
|
|
type: "risk",
|
|
|
|
|
|
severity: risk.severity || "warning",
|
|
|
|
|
|
device: risk.device || "未知设备",
|
|
|
|
|
|
detail: risk.observation || risk.message || String(risk),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
// 命令异常(在 anomalies 数组里)→ timeline event
|
|
|
|
|
|
for (const a of (ai.anomalies || [])) {
|
|
|
|
|
|
timeline.push({
|
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
|
time: timeStr,
|
|
|
|
|
|
snapshot_id: snap.id,
|
|
|
|
|
|
type: "anomaly",
|
|
|
|
|
|
severity: "warning",
|
|
|
|
|
|
device: a.device || "未知设备",
|
|
|
|
|
|
detail: a.cmd || a.message || String(a),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 无 AI 汇总的快照(可能 AI 失败/未触发),也记录基础事实
|
|
|
|
|
|
const offlineDevs = snap.devices.filter(d => !d.ok);
|
|
|
|
|
|
if (offlineDevs.length > 0) {
|
|
|
|
|
|
for (const d of offlineDevs) {
|
|
|
|
|
|
timeline.push({
|
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
|
time: timeStr,
|
|
|
|
|
|
snapshot_id: snap.id,
|
|
|
|
|
|
type: "offline",
|
|
|
|
|
|
severity: "critical",
|
|
|
|
|
|
device: d.hostname,
|
|
|
|
|
|
detail: "本次巡检整体失败",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按时间升序排(时间线要串起来)
|
|
|
|
|
|
timeline.sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
|
|
|
|
|
|
|
|
// 设备状态矩阵(哪些设备在今天的哪些时间点出问题)
|
|
|
|
|
|
const deviceStatusMatrix = {}; // hostId → [{ time, snapshot_id, status, note }]
|
|
|
|
|
|
for (const snap of filtered) {
|
|
|
|
|
|
const ts = snap.timestamp;
|
|
|
|
|
|
const timeStr = new Date(ts).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
|
|
|
|
for (const dev of snap.devices) {
|
|
|
|
|
|
if (!deviceStatusMatrix[dev.device_id]) {
|
|
|
|
|
|
deviceStatusMatrix[dev.device_id] = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
let status = "ok";
|
|
|
|
|
|
let note = "";
|
|
|
|
|
|
if (!dev.ok) { status = "offline"; note = "SSH 连接失败"; }
|
|
|
|
|
|
else {
|
|
|
|
|
|
const failCmds = dev.commands.filter(c => !c.ok);
|
|
|
|
|
|
if (failCmds.length > 0) { status = "partial"; note = `${failCmds.length} 条命令失败`; }
|
|
|
|
|
|
}
|
|
|
|
|
|
deviceStatusMatrix[dev.device_id].push({
|
|
|
|
|
|
time: timeStr, snapshot_id: snap.id, status, note,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const report = {
|
|
|
|
|
|
id: `daily_${dateStr}_${mode}`,
|
|
|
|
|
|
date: dateStr,
|
|
|
|
|
|
mode,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
trigger,
|
|
|
|
|
|
period: { start_ts: startTs, end_ts: endTs },
|
|
|
|
|
|
summary: {
|
|
|
|
|
|
snapshotCount: filtered.length,
|
|
|
|
|
|
deviceCount: deviceMap.size,
|
|
|
|
|
|
totalCmds,
|
|
|
|
|
|
okCmds,
|
|
|
|
|
|
failCmds: totalCmds - okCmds,
|
|
|
|
|
|
failRate: totalCmds > 0 ? Number(((totalCmds - okCmds) / totalCmds * 100).toFixed(1)) : 0,
|
|
|
|
|
|
deviceSuccessRate,
|
|
|
|
|
|
topFailedCommands,
|
|
|
|
|
|
},
|
|
|
|
|
|
timeline, // ═══ 新增:故障时间线 ═══
|
|
|
|
|
|
perSnapshotHighlights, // ═══ 新增:每个快照的健康摘要(按时间)═══
|
|
|
|
|
|
deviceStatusMatrix, // ═══ 新增:设备×时间点状态矩阵 ═══
|
|
|
|
|
|
aiSummary: null, // 日报告自己的 AI 汇总(可选生成)
|
|
|
|
|
|
};
|
|
|
|
|
|
return report;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 保存一份日报告到存储,同一 date+mode 存在则覆盖
|
|
|
|
|
|
*/
|
|
|
|
|
|
function addDailyReport(readFn, writeFn, report) {
|
|
|
|
|
|
_ensureLoaded(readFn);
|
|
|
|
|
|
const idx = _reports.findIndex(r => r.date === report.date && r.mode === report.mode);
|
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
|
_reports[idx] = report;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_reports.push(report);
|
|
|
|
|
|
}
|
|
|
|
|
|
_reports.sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
|
|
_save(writeFn);
|
|
|
|
|
|
return report;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function listDailyReports(readFn, limit = 30) {
|
|
|
|
|
|
_ensureLoaded(readFn);
|
|
|
|
|
|
return _reports.slice(0, limit);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function deleteDailyReport(readFn, writeFn, id) {
|
|
|
|
|
|
_ensureLoaded(readFn);
|
|
|
|
|
|
const idx = _reports.findIndex(r => r.id === id);
|
|
|
|
|
|
if (idx < 0) return false;
|
|
|
|
|
|
_reports.splice(idx, 1);
|
|
|
|
|
|
_save(writeFn);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearAll(readFn, writeFn) {
|
|
|
|
|
|
_reports = [];
|
|
|
|
|
|
_save(writeFn);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
function _resetForTest() { _reports = null; }
|
|
|
|
|
|
|
2026-10-15 13:53:04 +08:00
|
|
|
|
module.exports = {
|
|
|
|
|
|
MAX_DAILY_REPORTS,
|
|
|
|
|
|
buildDailyReport,
|
|
|
|
|
|
addDailyReport,
|
|
|
|
|
|
listDailyReports,
|
|
|
|
|
|
deleteDailyReport,
|
|
|
|
|
|
clearAll,
|
|
|
|
|
|
_dayStart,
|
|
|
|
|
|
_dayEnd,
|
|
|
|
|
|
_dateStr,
|
2026-10-19 21:11:57 +08:00
|
|
|
|
_resetForTest,
|
2026-10-15 13:53:04 +08:00
|
|
|
|
};
|