Files
SenMeshNet/electron/bridges/inspection/index.cjs
zhaolei 1c3c8c4b20
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
fix: 巡检报告快照 3 层防重复锁
修复巡检报告快照可能多次生成的竞态:

1. reportScheduler._inProgress —— 快照执行 >60s 时,
   下一个 tick 进来直接跳过,防止定时 tick 重入
2. index.cjs._isSnapshotRunning 双向互斥 ——
   reportScheduler.tick 和 IPC 手动触发路径互相感知,
   谁先进来谁锁,另一条路径跳过
3. IPC inspection:runReportSnapshot 入口拦截 ——
   用户连点"立即巡检"按钮被拒绝

之前已修:renderer scheduler:start 不再触发 scanAllNow。
2026-10-21 17:13:21 +08:00

2166 lines
88 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";
/**
* 巡检模块统一入口
*
* 提供 IPC handler 注册函数,供主进程注册。
* 渲染进程通过 IPC 调用巡检相关功能。
*/
const deviceStore = require("./deviceStore.cjs");
const alertRuleStore = require("./alertRuleStore.cjs");
const inspectionResultStore = require("./inspectionResultStore.cjs");
const alertRecordStore = require("./alertRecordStore.cjs");
const adapterRegistry = require("./adapterRegistry.cjs");
const InspectionScheduler = require("./inspectionScheduler.cjs");
const { execSshCommand, execSshCommands } = require("./inspectionExec.cjs");
const reportCommands = require("./reportCommands.cjs");
const reportStore = require("./reportStore.cjs");
const reportScheduleStore = require("./reportScheduleStore.cjs");
const dailyReportStore = require("./dailyReportStore.cjs");
const reportScheduler = require("./reportScheduler.cjs");
const reportExport = require("./reportExport.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const {
ALERT_LEVEL_LABELS,
DEVICE_TIER_LABELS,
INSPECTION_STATUS_LABELS,
ALERT_RECORD_STATUS_LABELS,
OPERATOR_LABELS,
DEVICE_CATALOG_LABELS,
METRIC_LABELS,
INSPECTION_STATUS,
} = require("./types.cjs");
let _scheduler = null;
let _isSnapshotRunning = false; // 巡检执行状态(主进程持有,renderer 可查询)
// 读报告产品名(PDF 巡检报告标题专用)
// 优先级:_report_product_name(新)> _product_name(新)> report_product_name_v1(旧带前缀)> product_name_v1(旧带前缀)> 默认值
function _readProductName(readFn) {
// 新 key(IPC save 写的)
try {
const raw = readFn("_report_product_name");
if (typeof raw === "string" && raw.trim()) return raw.trim();
} catch (_) {}
try {
const raw = readFn("_product_name");
if (typeof raw === "string" && raw.trim()) return raw.trim();
} catch (_) {}
// 旧带前缀 key(可能 JSON.stringify 过)
try {
const raw = readFn("netcatty_report_product_name_v1");
const name = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
if (typeof name === "string" && name.trim()) return name.trim();
} catch (_) {}
try {
const raw = readFn("netcatty_product_name_v1");
const name = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
if (typeof name === "string" && name.trim()) return name.trim();
} catch (_) {}
return process.env.NETCATTY_PRODUCT_NAME || "NetMesh 智能巡检系统";
}
// broadcastAlertChanged 去抖:1 秒内多次调用合并为一次广播
let _broadcastTimer = null;
let _pendingBroadcast = false;
function broadcastAlertChanged() {
_pendingBroadcast = true;
if (_broadcastTimer) return;
_broadcastTimer = setTimeout(() => {
_broadcastTimer = null;
if (!_pendingBroadcast) return;
_pendingBroadcast = false;
try {
const { webContents } = require("electron");
for (const wc of webContents.getAllWebContents()) {
wc.send("inspection:alertChanged");
}
} catch (_) {}
}, 1000);
}
// 规则变化广播(立即,不去抖)
function broadcastRuleChanged() {
try {
const { webContents } = require("electron");
for (const wc of webContents.getAllWebContents()) {
wc.send("inspection:ruleChanged");
}
} catch (_) {}
}
// AI 汇总状态广播(主进程主动推送 → renderer loadSnapshots 刷新)
function _broadcastReportAiStatus(snapshotId, status, error) {
try {
const { webContents } = require("electron");
for (const wc of webContents.getAllWebContents()) {
wc.send("inspection:reportAiStatusChanged", { snapshotId, status, error });
}
} catch (_) {}
}
// ─── AI 汇总 helpers(主进程 CJS 版本)──────────────────────
const _AI_SYSTEM_PROMPT = `你是一名资深运维工程师,负责汇总服务器/交换机/防火墙的定期巡检报告。
【核心原则 — 必须先读这三条】
❶ 只关注"运行时参数异常",不关注"硬件结构性事实"。结构性事实指:单电源、无 RAID、无风扇备份、主板型号、dmidecode 显示的内存条数量、磁盘品牌等 —— 这些是设备固有属性,每台设备每次巡检都一样,提它没有运维价值。
❷ 跨平台命令失败不算异常。Linux 服务器跑交换机命令(show version / display vlan)报 command not found,或交换机跑 Linux 命令(free / df / iostat)报未识别,这是巡检框架 fallback 机制的自然结果,不算命令失败、不进入 issues、不触发 hidden_risks。只有"本 catalog 应该支持的命令"(如 linux_server 组的 free、huawei_h3c_switch 组的 display cpu)失败才值得关注。
❸ hidden_risks 必须是"参数偏离正常范围"的信号。正常交换机温度 40-55°C、服务器 CPU 使用率 <70%、内存使用率 <80%、磁盘使用率 <85%、swap 使用 <30%,这些范围内的数值不报。只有超阈值才提。
❹ 内网环境,严禁输出任何外部 URL / 厂商官网链接 / 技术文档链接(如 management.h3c.com、www.kylinos.cn、cisco.com/wiki 等),严禁引用厂商文档名称和章节号,严禁推荐访问外网的工具或步骤。设备固件版本、硬件型号等信息必须从巡检命令的 stdout 输出中提取,不得脑补。
输出 JSON:
{
"health_summary": "一句话整体评估(X 台在线 / Y 台离线,命令成功率 %,关键异常一句话,无异常则写"本轮巡检全部正常")",
"offline_devices": [ { "device": "hostname", "reason": "SSH连接超时|认证失败|ping不通" } ],
"device_status": [
{
"device": "hostname",
"catalog": "huawei_h3c_switch | linux_server | cisco_switch | unknown",
"running_ok": true,
"highlights": ["这台设备本轮关键正常参数,如 CPU 23% / 温度 48°C / 无 error 日志"],
"issues": ["本 catalog 应有但失败的命令(排除 command not found)"]
}
],
"hidden_risks": [
{
"device": "hostname",
"observation": "具体数值 + 异常点描述(如 temperature 63°C 超阈值 60°C,或 CPU 持续 91%)",
"severity": "critical | warning | info"
}
],
"recommendations": ["按优先级排序的处置建议,必须具体到设备和操作,如:重启 host-a 的 nginx 进程、联系机房检查 host-b 散热"]
}
【hidden_risks 触发条件 — 只在满足以下情况时输出】
✅ 温度超阈值:交换机 >60°C,服务器 CPU >80°C,磁盘 SMART 警告
✅ 资源超阈值:CPU load >核数×1.5 持续存在、内存使用率 >85%、磁盘使用率 >90%、inode >90%、swap 使用率 >40%、fd 使用率 >80%
✅ 网络异常:端口 CRC error / input error / output drop 非零、网卡 RX/TX error 包数持续增长、默认网关 ping 丢包
✅ NTP 漂移:stratum 频繁切换、offset >100ms、last_sync 超过 30min
✅ 日志异常:kernel panic / oops / segfault(critical)、OOM killed process(critical)、JOURNAL_ERR error 级(warning)、JOURNAL_WARN 周期性重复 warning(warning)、SSH 5min 内 >3 次失败密码(warning,暴力破解嫌疑)、audit denied(info/warning)、sudo 非运维时段非常规命令(warning)、failed systemd service(warning)
✅ 安全异常:挖矿/反弹 shell 可疑进程、非标准 root 进程路径、异常 SUID 文件
❌ 以下情况不要输出 hidden_risks:
❌ 硬件结构性:无 RAID、无冗余电源、无冗余风扇、内存条数量、磁盘品牌/型号
❌ 平台差异:交换机上 free/df/iostat 报 command not found、Linux 上 show version/display vlan 报未识别
❌ 正常波动:CPU 短时尖峰(<30s)、内存 buffer/cache 正常回收、磁盘使用率 85-89%(还没到 90%)、温度在阈值边缘抖动(如 58°C / 阈值 60°C)
severity 分级:
- critical:kernel panic/OOM/segfault、设备离线、SSH 连接超时、磁盘即将写满 >95%、温度 >85°C
- warning:资源超阈值(CPU >85% / MEM >90% / DISK >90%)、端口 CRC error、SSH 暴力破解嫌疑、failed systemd service、周期性 error 日志
- info:偶发 warning 日志、swap 偏高但还没用、inode >85%、fd >70%
严格输出合法 JSON,不要额外解释`;
function _classifyCmd(cmdName) {
const n = (cmdName || "").toLowerCase();
if (/version|device|hostname|uptime|uname|show version/.test(n)) return "base";
if (/memory|cpu|temperature|fan|power|free -m|vmstat|top -bn1/.test(n)) return "resource";
if (/interface|arp|vlan|stp|vrrp|ntp|route|neigh|ip -br|ip link|ip addr|display ip/.test(n)) return "network";
// 日志类: journalctl(包括 -p warning/err/alert/-k/_COMM)、logbuffer、dmesg、syslog、messages、audit、ausearch、sshd、sudo、lastb
if (/logbuffer|journalctl|dmesg|messages|syslog|show log|audit|ausearch|sshd|sudo|lastb|failed_auth|warn|kernel_panic|oom|recent_30min/.test(n)) return "log";
// "log" 关键字兜底(purpose 里带 LOG 的命令也归入 log 类)
if (/log|日志|audit|auth/.test(n)) return "log";
return "network";
}
function _truncateStdout(cmdName, stdout) {
if (!stdout) return "";
const cls = _classifyCmd(cmdName);
if (cls === "log") {
// 日志首尾都看:前 20 行 + 后 60 行,中间 4000 字符心跳重复日志跳过
const lines = stdout.split("\n");
if (lines.length <= 80) return stdout;
const head = lines.slice(0, 20).join("\n");
const tail = lines.slice(-60).join("\n");
return `${head}\n...[中间 ${lines.length - 80} 行已跳过]...\n${tail}`;
}
if (cls === "network") return stdout.slice(0, 800);
return stdout.slice(0, 2000);
}
function _buildSnapshotPrompt(snapshot) {
const offlineDevices = [];
const onlineDevices = [];
for (const d of snapshot.devices || []) {
const cmds = d.commands || [];
const allFail = cmds.every(c => !c.ok);
const isConnectionErr = cmds.every(c =>
/Connection refused|ETIMEDOUT|ECONNREFUSED|connect failed|authentication failed|SSH.*failed/i.test((c.stderr || c.stdout || ""))
);
const isOffline = !d.ok || (allFail && isConnectionErr);
if (isOffline) {
offlineDevices.push({
device: d.hostname,
reason: (cmds[0]?.stderr || cmds[0]?.stdout || "SSH连接失败").slice(0, 150),
});
} else {
// 大幅压缩:只保留失败命令 + 最多 5 条关键资源命令,输出截断到 200 字符
const detailCmds = [];
let hasFail = false;
let resourceCount = 0;
const MAX_RESOURCE = 5;
for (const c of cmds) {
if (!c.ok) {
hasFail = true;
detailCmds.push({
cmd: c.cmd, status: "fail",
output: (c.stdout || c.stderr || "").slice(0, 200),
});
continue;
}
const cls = _classifyCmd(c.cmd);
// 只保留 resource 类型(cpu/mem/磁盘/端口等关键指标),跳过 log 类型
if (cls === "resource" && resourceCount < MAX_RESOURCE) {
resourceCount++;
detailCmds.push({ cmd: c.cmd, status: "ok", output: (c.stdout || "").slice(0, 200) });
}
}
onlineDevices.push({
device: d.hostname,
catalog: d.catalog_id,
status: hasFail ? "partial_fail" : "ok",
command_total: cmds.length,
command_failed: cmds.filter(c => !c.ok).length,
commands: detailCmds,
});
}
}
const totalCmds = onlineDevices.reduce((a, d) => a + d.command_total, 0);
const failCmds = onlineDevices.reduce((a, d) => a + d.command_failed, 0);
// 压缩 JSON:用 null 替代缩进,减少 30-40% 体积
return JSON.stringify({
offline: offlineDevices,
online: onlineDevices,
summary: {
ts: new Date(snapshot.timestamp).toISOString(),
trigger: snapshot.trigger,
dur_ms: snapshot.duration_ms,
offline_n: offlineDevices.length,
online_n: onlineDevices.length,
total_cmd: totalCmds,
fail_cmd: failCmds,
},
});
}
function _resolveAiStyle(provider) {
const lower = (provider.baseURL || provider.id || "").toLowerCase();
if (/anthropic|claude/.test(lower)) return "anthropic";
if (/google|gemini/.test(lower)) return "google";
return "openai";
}
function _buildAiUrl(provider) {
const base = (provider.baseURL || "").replace(/\/$/, "");
const style = _resolveAiStyle(provider);
const suffix = style === "anthropic" ? "/v1/messages"
: style === "google" ? `/v1beta/models/${provider.defaultModel || "gemini-pro"}:generateContent`
: "/chat/completions";
return base + suffix;
}
function _buildAiBody(provider, userContent) {
const style = _resolveAiStyle(provider);
const systemPrompt = _AI_SYSTEM_PROMPT;
const base = { model: provider.defaultModel || "gpt-4o-mini", temperature: 0.3 };
if (style === "google") {
return { ...base, contents: [{ role: "user", parts: [{ text: `${systemPrompt}\n\n${userContent}` }] }] };
}
if (style === "anthropic") {
return { ...base, system: systemPrompt, messages: [{ role: "user", content: userContent }], max_tokens: 2048 };
}
return { ...base, messages: [{ role: "system", content: systemPrompt }, { role: "user", content: userContent }] };
}
function _parseAiResponse(provider, rawData) {
let data;
try { data = typeof rawData === "string" ? JSON.parse(rawData) : rawData; } catch { return ""; }
const style = _resolveAiStyle(provider);
if (style === "google") return data?.candidates?.[0]?.content?.parts?.[0]?.text || "";
if (style === "anthropic") return data?.content?.[0]?.text || "";
return data?.choices?.[0]?.message?.content || "";
}
function _extractJson(text) {
if (!text) return null;
let t = text.trim().replace(/^```json\s*/i, "").replace(/\s*```\s*$/, "");
const m = t.match(/\{[\s\S]*\}/);
if (!m) return null;
try { return JSON.parse(m[0]); } catch { return null; }
}
/**
* Node.js http/https fetch(类似 aiBridge 的 doFetch,但不需要 redirect 处理)
*/
function _doHttpFetch(urlStr, headers, body, opts) {
return new Promise((resolve) => {
const http = require("http");
const https = require("https");
try {
const parsed = new URL(urlStr);
const lib = parsed.protocol === "https:" ? https : http;
const timeoutMs = opts?.timeoutMs || 60000;
const skipTLS = !!opts?.skipTLS;
const req = lib.request(parsed, {
method: "POST",
headers: { ...headers, "Content-Length": Buffer.byteLength(body || "") },
timeout: timeoutMs,
rejectUnauthorized: skipTLS ? false : undefined,
}, (res) => {
let data = "";
res.on("data", (chunk) => data += chunk.toString());
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
data,
error: res.statusCode >= 300 ? `HTTP ${res.statusCode}` : null,
});
});
});
req.on("error", (err) => resolve({ ok: false, status: 0, data: "", error: err.message }));
req.on("timeout", () => { req.destroy(); resolve({ ok: false, status: 0, data: "", error: "Request timeout" }); });
if (body) req.write(body);
req.end();
} catch (err) {
resolve({ ok: false, status: 0, data: "", error: err.message });
}
});
}
/**
* 获取标签映射(前端展示用)
*/
function getLabels() {
return {
alert_levels: ALERT_LEVEL_LABELS,
device_tiers: DEVICE_TIER_LABELS,
inspection_status: INSPECTION_STATUS_LABELS,
alert_record_status: ALERT_RECORD_STATUS_LABELS,
operators: OPERATOR_LABELS,
device_catalog: DEVICE_CATALOG_LABELS,
metrics: METRIC_LABELS,
};
}
/**
* 注册巡检模块的 IPC handlers
*
* @param {object} ipcMain - Electron ipcMain
* @param {object} options
* @param {function} options.readStorage - 读取本地存储 (key) => string
* @param {function} options.writeStorage - 写入本地存储 (key, value) => void
*/
function registerInspectionHandlers(ipcMain, options) {
const { readStorage, writeStorage, sessions } = options;
const readFn = (key) => readStorage(key);
const writeFn = (key, val) => writeStorage(key, val);
// 根据主机地址查找会话(用于复用终端 SSH 连接)
function findSessionByHost(hostname) {
const DEBUG = process.env.INSPECTION_DEBUG === "1";
if (DEBUG) console.log("[Inspection] 查找会话,总会话数:", sessions?.size || 0);
if (!sessions) {
if (DEBUG) console.log("[Inspection] sessions 未初始化");
return null;
}
for (const session of sessions.values()) {
if (!session) continue;
// SSH 会话才有 conn
if (!session.conn) continue;
// 匹配主机地址
const sessionHost = session.hostname || session.host || "";
if (DEBUG) {
console.log("[Inspection] SSH会话:", sessionHost, "| 目标:", hostname);
}
if (sessionHost === hostname) {
if (DEBUG) console.log("[Inspection] 匹配到会话:", sessionHost);
return session;
}
}
return null;
}
// ---------- 标签映射 ----------
ipcMain.handle("inspection:getLabels", () => {
return getLabels();
});
// ---------- 系统配置 ----------
ipcMain.handle("inspection:getSysConfig", () => {
return deviceStore.getSysConfig(readFn);
});
ipcMain.handle("inspection:updateSysConfig", (_e, updates) => {
return deviceStore.updateSysConfig(readFn, writeFn, updates);
});
// ---------- 设备类型 ----------
ipcMain.handle("inspection:getCatalog", () => {
return deviceStore.getCatalog();
});
ipcMain.handle("inspection:getTiers", () => {
return deviceStore.getTiers();
});
ipcMain.handle("inspection:getAdapters", () => {
return adapterRegistry.getRegisteredAdapters();
});
// ---------- 设备扩展配置 ----------
ipcMain.handle("inspection:getAllExtensions", () => {
return deviceStore.getAllExtensions(readFn);
});
ipcMain.handle("inspection:getExtension", (_e, hostId) => {
return deviceStore.getExtension(readFn, hostId);
});
ipcMain.handle("inspection:upsertExtension", (_e, hostId, data) => {
return deviceStore.upsertExtension(readFn, writeFn, hostId, data);
});
// 批量设置所有设备的 inspection_enabled
ipcMain.handle("inspection:batchSetInspectionEnabled", (_e, enabled) => {
try {
const all = deviceStore.getAllExtensions(readFn);
let count = 0;
for (const ext of Object.values(all)) {
deviceStore.upsertExtension(readFn, writeFn, ext.host_id, {
inspection_enabled: !!enabled,
});
count++;
}
console.log(`[Inspection] 批量设置巡检状态: ${enabled ? "启用" : "停止"} ${count} 台设备`);
return { ok: true, count, enabled: !!enabled };
} catch (e) {
console.error("[Inspection] 批量设置巡检状态失败:", e);
return { ok: false, error: e.message };
}
});
ipcMain.handle("inspection:deleteExtension", (_e, hostId) => {
return deviceStore.deleteExtension(readFn, writeFn, hostId);
});
ipcMain.handle("inspection:getEffectiveInterval", (_e, hostId) => {
return deviceStore.getEffectiveInterval(readFn, hostId);
});
ipcMain.handle("inspection:getEnabledDevices", () => {
return deviceStore.getEnabledDevices(readFn);
});
// ---------- 告警规则 ----------
ipcMain.handle("inspection:listRules", () => {
return alertRuleStore.listRules(readFn);
});
ipcMain.handle("inspection:getRule", (_e, ruleId) => {
return alertRuleStore.getRule(readFn, ruleId);
});
ipcMain.handle("inspection:createRule", (_e, data) => {
const result = alertRuleStore.createRule(readFn, writeFn, data);
broadcastRuleChanged();
return result;
});
ipcMain.handle("inspection:updateRule", (_e, ruleId, updates) => {
const result = alertRuleStore.updateRule(readFn, writeFn, ruleId, updates);
broadcastRuleChanged();
return result;
});
ipcMain.handle("inspection:deleteRule", (_e, ruleId) => {
const result = alertRuleStore.deleteRule(readFn, writeFn, ruleId);
broadcastRuleChanged();
return result;
});
ipcMain.handle("inspection:toggleRule", (_e, ruleId, enabled) => {
const result = alertRuleStore.toggleRule(readFn, writeFn, ruleId, enabled);
broadcastRuleChanged();
return result;
});
// ---------- 巡检结果 ----------
ipcMain.handle("inspection:getLatestAll", () => {
return inspectionResultStore.getLatestAll(readFn);
});
ipcMain.handle("inspection:getLatestByDevice", (_e, hostId, limit) => {
return inspectionResultStore.getLatestByDevice(readFn, hostId, limit);
});
ipcMain.handle("inspection:getRecentResults", (_e, limit) => {
return inspectionResultStore.getRecent(readFn, limit);
});
// ---------- 单次巡检执行(前端传入完整主机配置) ----------
ipcMain.handle("inspection:runInspection", async (_e, payload) => {
const { hostId, hostConfig, catalogId } = payload || {};
if (!hostId || !hostConfig) {
return { success: false, error: "缺少主机配置" };
}
const DEBUG = process.env.INSPECTION_DEBUG === "1";
if (DEBUG) {
console.log("[Inspection] 巡检配置:", JSON.stringify({
host: hostConfig.hostname || hostConfig.address,
port: hostConfig.port || 22,
username: hostConfig.username,
hasPassword: !!hostConfig.password,
hasPrivateKey: !!hostConfig.privateKeyContent,
hasKeyId: !!hostConfig.identityFileId,
}));
}
const sshConfig = {
host: hostConfig.hostname || hostConfig.address,
port: hostConfig.port || 22,
username: hostConfig.username,
password: hostConfig.password,
privateKey: hostConfig.privateKeyContent,
passphrase: hostConfig.passphrase,
};
if (!sshConfig.host || !sshConfig.username) {
return { success: false, error: "主机地址或用户名缺失" };
}
const startTime = Date.now();
const result = {
id: `ins_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
host_id: hostId,
catalog_id: catalogId || "linux_server",
start_time: startTime,
end_time: 0,
status: INSPECTION_STATUS.SUCCESS,
metrics: {},
raw: {},
error: null,
alerts: [],
};
try {
// 构造 execFn — 优先复用终端会话的 SSH 连接
const execFn = async (command, options = {}) => {
const timeoutMs = options.timeoutMs || 60000;
const DEBUG = process.env.INSPECTION_DEBUG === "1";
// 1. 先尝试复用终端会话的连接
const session = findSessionByHost(sshConfig.host);
if (DEBUG) {
console.log("[Inspection] 查找会话:", sshConfig.host,
"| 找到会话:", session ? "是" : "否",
"| 有连接:", session?.conn ? "是" : "否");
}
if (session && session.conn) {
try {
const result = await executeBoundedSshCommand(session.conn, command, {
openingTimeoutMs: timeoutMs,
runTimeoutMs: timeoutMs,
maxOutputBytes: 100 * 1024,
});
if (DEBUG) console.log("[Inspection] 复用会话成功, stdout:", result.stdout?.slice(0, 100));
return result.stdout + (result.stderr ? "\n" + result.stderr : "");
} catch (e) {
// 终端连接失败,回退到新建连接
if (DEBUG) console.log("[Inspection] 复用会话失败,回退:", e.message);
}
}
// 2. 回退:新建 SSH 连接(connectTimeoutMs 提高到 20s — 公网服务器握手需要更长时间)
return await execSshCommand(sshConfig, command, {
connectTimeoutMs: 20000,
execTimeoutMs: timeoutMs,
});
};
// 构造批量执行函数(一次连接执行多条命令)
const execCommandsFn = async (commands, options = {}) => {
const { execSshCommands } = require("./inspectionExec.cjs");
return await execSshCommands(sshConfig, commands, options);
};
// 获取设备类型对应的采集器类名
const catalogs = deviceStore.getCatalog();
const catalog = catalogs.find((c) => c.id === catalogId);
const adapterClassName = catalog?.adapter_class || "LinuxServerAdapter";
// 创建采集器实例(注入 execFn 和 execCommandsFn)
const adapter = adapterRegistry.createAdapter(adapterClassName, sshConfig, {
execFn,
execCommands: execCommandsFn,
timeoutMs: 20000,
});
if (!adapter) {
throw new Error("采集器加载失败: " + adapterClassName);
}
const collectResult = await adapter.collect();
result.metrics = collectResult.metrics || {};
result.raw = collectResult.raw || {};
if (!collectResult.online) {
result.status = collectResult.status || INSPECTION_STATUS.FAILED;
result.error = collectResult.error || "设备离线或连接失败";
}
} catch (err) {
result.status = INSPECTION_STATUS.FAILED;
result.error = err.message;
console.error("[Inspection] runInspection error:", err.message);
}
result.end_time = Date.now();
const duration = result.end_time - result.start_time;
// 构造设备信息(用于告警记录)
const deviceInfo = {
device_id: hostId,
device_name: hostConfig.label || hostConfig.hostname || hostId,
catalog_id: catalogId || "linux_server",
};
// 保存巡检结果
try {
inspectionResultStore.addResult(readFn, writeFn, {
...result,
device_id: hostId,
online: result.status === INSPECTION_STATUS.SUCCESS,
duration_ms: duration,
error_message: result.error,
});
} catch (e) {
console.warn("[Inspection] 保存巡检结果失败:", e.message);
}
// 更新设备 last_scan_at —— 关键!renderer 侧 runDeviceInspection 不更新这个字段,
// 主进程 InspectionScheduler 读这个字段判断过期,如果这里不更新会导致重复巡检
try {
deviceStore.updateLastScan(
readFn,
writeFn,
hostId,
result.status,
result.error || null
);
} catch (e) {
console.warn("[Inspection] updateLastScan 失败:", e.message);
}
// 告警判断(成功/失败都要判断,特别是 online 指标)
const triggeredAlerts = [];
try {
const deviceExt = deviceStore.getExtension(readFn, hostId);
const rules = alertRuleStore.getRulesForDevice(readFn, deviceExt || { host_id: hostId, catalog_id: catalogId });
const isOnline = result.status === INSPECTION_STATUS.SUCCESS;
for (const rule of rules) {
// online 指标 — 成功/失败都要判断(设备离线告警)
if (rule.metric === "online") {
const shouldAlert = rule.threshold === "false" ? !isOnline : isOnline;
const alertResult = alertRecordStore.processAlert(
readFn,
writeFn,
deviceInfo,
rule,
isOnline ? "在线" : "离线",
shouldAlert
);
if (alertResult) triggeredAlerts.push(alertResult);
continue;
}
// 普通指标 — 只在成功时判断(失败时没有指标数据)
if (isOnline) {
const metricValue = result.metrics[rule.metric];
if (metricValue === undefined || metricValue === null) continue;
const alertResult = alertRecordStore.processAlert(
readFn,
writeFn,
deviceInfo,
rule,
metricValue,
alertRuleStore.evaluateRule(metricValue, rule.operator, rule.threshold)
);
if (alertResult) triggeredAlerts.push(alertResult);
}
}
} catch (e) {
console.warn("[Inspection] 告警判断失败:", e.message);
}
// 执行错误告警 —— timeout/failed 也要进告警中心
// 放在 processAlert 之后执行:如果 processAlert 已经创建了 rule_offline,
// createExecutionErrorAlert 会用更有意义的错误信息(SSH连接失败/超时等)覆盖 message
if (result.status !== INSPECTION_STATUS.SUCCESS) {
alertRecordStore.createExecutionErrorAlert(
readFn,
writeFn,
deviceInfo,
result.status,
result.error || "巡检执行失败"
);
broadcastAlertChanged();
}
// 告警变化后广播(触发/更新/恢复都通知)
if (triggeredAlerts.length > 0) {
broadcastAlertChanged();
}
return {
success: result.status === INSPECTION_STATUS.SUCCESS,
status: result.status,
metrics: result.metrics,
error: result.error,
duration,
alerts: triggeredAlerts,
};
});
// ---------- AI RCA 单条命令执行(接收完整 hostConfig,独立 SSH) ----------
// 供 RCA agent 用:rcaRunner 在 renderer 侧解密 vault hosts+keys 构建 hostConfig,
// 传入这里让主进程用 execSshCommand 独立建 SSH 连设备执行一条命令。
ipcMain.handle("inspection:runSingleCommand", async (_e, payload) => {
const { hostConfig, command, timeoutMs = 15000 } = payload || {};
console.log("[Inspection:runSingleCommand] payload:", JSON.stringify({
hostname: hostConfig?.hostname,
port: hostConfig?.port,
username: hostConfig?.username,
hasPassword: !!hostConfig?.password,
hasPrivateKey: !!hostConfig?.privateKeyContent,
command,
timeoutMs,
}));
if (!hostConfig?.hostname || !hostConfig?.username || !command) {
return { ok: false, error: "缺少 hostConfig 或 command" };
}
const sshConfig = {
host: hostConfig.hostname,
port: hostConfig.port || 22,
username: hostConfig.username,
password: hostConfig.password,
privateKey: hostConfig.privateKeyContent,
passphrase: hostConfig.passphrase,
};
const start = Date.now();
try {
console.log("[Inspection:runSingleCommand] 正在 SSH 连接", sshConfig.host, "...");
// 自动拆分分号命令 —— bash/sh 支持 "cmd1; cmd2" 一条 exec 里跑多条,
// 但华为 CLI / Cisco IOS / 防火墙 VTY 不认分号拼接,需要拆成多条顺序执行。
let commands = command
.split(/;\s*(?=(?:[^"']*"[^"']*")*[^"']*$)/) // 不拆引号内的分号
.map(c => c.trim())
.filter(Boolean);
// 关闭分页命令兜底替换:AI 有时会给出错误的命令,自动修正
// terminal length 0 → screen-length 0 temporary(华为/H3C 用户视图)
// terminal length 0 line → terminal length 0 line (华为 system-view)
// terminal length 100 → screen-length 100 temporary
commands = commands.map(c => {
// 华为 CLI 常见的分页关闭命令替换(用户视图下有效)
if (/^terminal\s+length\s+0(\s+line)?$/i.test(c)) return "screen-length 0 temporary";
if (/^terminal\s+length\s+\d+$/i.test(c)) return c.replace(/^terminal\s+length\s+(\d+)/, "screen-length $1 temporary");
// Cisco IOS
if (/^terminal\s+length\s+0$/i.test(c)) return "terminal length 0"; // IOS 这条是对的,不替换
return c;
});
console.log("[Inspection:runSingleCommand] 拆分后命令数:", commands.length, commands);
let allStdout = "";
let allOk = true;
// connectTimeout 也尊重短 timeoutMs(autoDetectCatalog 传 8000 时,连接超时也用 8s)
const connectTimeoutMs = Math.min(20000, Math.max(3000, timeoutMs));
if (commands.length === 1) {
// 单条命令,直接 execSshCommand
allStdout = await execSshCommand(sshConfig, commands[0], {
connectTimeoutMs,
execTimeoutMs: timeoutMs,
});
} else {
// 多条命令,顺序执行,拼接 stdout
const result = await execSshCommands(sshConfig, commands, {
connectTimeoutMs,
execTimeoutMs: Math.floor(timeoutMs / Math.max(commands.length, 1)),
});
allStdout = (result?.results || []).map(r => r?.output || "").join("\n");
// 只要有一条命令输出里含错误关键字就算 ok=false
const ERROR_PATTERNS = /unrecognized command|command found at|Unknown command|invalid input|Error:|failed/i;
if ((result?.results || []).some(r => ERROR_PATTERNS.test(r?.output || ""))) {
allOk = false;
}
}
// ANSI 控制序列 / 光标移动 / More 提示 — 统一过滤
// (execSshCommand 单条路径没有 cleanOutput,handler 兜底)
function stripControl(text) {
return (text || "")
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "") // CSI 序列
.replace(/\x1B\][^\x07\x1B]*(\x07|\x1B\\)/g, "") // OSC 序列
.replace(/\x1B[PX][^\x1B]*\x1B\\/g, "") // PM/APC 序列
.replace(/\x1B[@-_][0-9;]*[ -\/]*[@-~]/g, "") // 其他 ESC 序列
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "") // 剩余单控制字符
.replace(/\r/g, "")
.trim();
}
allStdout = stripControl(allStdout);
console.log("[Inspection:runSingleCommand] 成功,stdout 长度:", allStdout?.length || 0);
return {
ok: allOk,
stdout: typeof allStdout === "string" ? allStdout : String(allStdout ?? ""),
stderr: "",
exitCode: 0,
durationMs: Date.now() - start,
};
} catch (e) {
console.error("[Inspection:runSingleCommand] 失败:", e?.message || String(e));
return {
ok: false,
stdout: "",
stderr: e?.message || String(e),
exitCode: -1,
durationMs: Date.now() - start,
error: e?.message || String(e),
};
}
});
// ---------- AI RCA 完整实现(主进程版) ----------
// 用 _hostConfigs 直接拿 SSH 凭据,不需要 renderer 解密
// 支持 IPC handler(Electron 本地)和 remoteHttpServer 直接调用
const bridge = require("../aiBridge.cjs");
// 从 AI 响应中提取 JSON(剥离 <think>...</think>、```json 包裹、找第一个配对的 {...})
function _extractJson(text) {
let cleaned = (text || "")
.replace(/<think>[\s\S]*?<\/think>/gi, "") // 剥离思维链
.replace(/```json\s*/gi, "")
.replace(/```\s*/g, "")
.trim();
// 直接尝试 parse
try { return JSON.parse(cleaned); } catch {}
// 找第一个配对的 {...}
const openIdx = cleaned.indexOf("{");
if (openIdx >= 0) {
let depth = 0, closeIdx = -1;
for (let i = openIdx; i < cleaned.length; i++) {
if (cleaned[i] === "{") depth++;
else if (cleaned[i] === "}") { depth--; if (depth === 0) { closeIdx = i; break; } }
}
if (closeIdx > openIdx) {
const jsonStr = cleaned.slice(openIdx, closeIdx + 1);
try { return JSON.parse(jsonStr); } catch {}
}
}
throw new Error("无法从 AI 响应中提取 JSON,原始响应前 200 字: " + cleaned.slice(0, 200));
}
const PHASE1_SYSTEM = [
`你是 NetMesh 运维平台的 AI 诊断规划专家。`,
`## 设备 Catalog 与命令族`,
`| catalogId | 设备类型 | Shell | 推荐诊断命令 |`,
`|---|---|---|---|`,
`| linux | Linux 服务器 | bash/sh | top -bn1 | head -20, vmstat 1 2, free -m, cat /proc/meminfo | head -20, df -h, ps aux --sort=-%cpu | head -10, iostat -x 1 1 |`,
`| huawei_h3c_switch | 华为/H3C 交换机 | HUAWEI CLI | screen-length 0 temporary, display version, display device, display memory, display power, display fan, display logbuffer level warning size 512 |`,
`| cisco_switch | Cisco 交换机 | IOS CLI | terminal length 0, show version, show processes cpu sorted, show memory summary, show interfaces brief, show log tail 50 |`,
`## 输出格式(严格 JSON)`,
`{ "commands": [{ "command": "xxx", "purpose": "xxx" }], "reasoning": "一句话说明" }`,
`## 规则`,
`- 命令必须严格匹配 catalogId,不得臆造`,
`- 禁止 display cpu-usage(有刷新动画抓不到完整输出)`,
`- 华为/H3C 必须先加 screen-length 0 temporary 关闭分页`,
`- 不超过 6 条命令`,
`- 禁止输出外网 URL / 厂商文档链接`,
].join("\n");
const PHASE2_SYSTEM = [
`你是 NetMesh 智慧运维平台的 AI 根因分析专家。`,
`你收到了告警 + 设备巡检 metrics + 多条诊断命令执行输出。`,
`## 输出格式(严格 JSON)`,
`{`,
` "confidence": 85,`,
` "rootCauses": ["最可能根因", "次要可能性"],`,
` "summary": "2-3 句摘要",`,
` "recommendations": [`,
` { "priority": "immediate|short_term|long_term", "action": "要做什么", "command": "可选命令", "risk": "low|medium|high" }`,
` ]`,
`}`,
`## 规则`,
`- confidence 基于证据充分性`,
`- recommendations 至少 2 条最多 5 条,按紧急程度排序`,
`- 严禁输出外网 URL / 厂商文档 / 外部工具推荐`,
].join("\n");
async function _callAiForRCA(providerConfigsOrNull, systemPrompt, userContent, maxTokens = 2000) {
if (!providerConfigsOrNull || !providerConfigsOrNull.length) {
throw new Error("AI provider 未配置");
}
const provider = providerConfigsOrNull.find(p => p.enabled !== false);
if (!provider) throw new Error("没有启用的 AI provider");
const resolved = bridge.resolveProviderApiKey(provider.id);
if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
const url = provider.endpoint || "https://api.minimaxi.com/v1/chat/completions";
const model = provider.model || "MiniMax-M1";
const patched = bridge.injectApiKeyIntoRequest(url, {}, provider.id);
const http = require("http"), https = require("https");
const lib = patched.url.startsWith("https") ? https : http;
const u = new URL(patched.url);
const bodyStr = JSON.stringify({
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
],
temperature: 0.3,
max_tokens: maxTokens,
stream: false,
});
return await new Promise((resolve, reject) => {
const req = lib.request({
hostname: u.hostname,
port: u.port || (u.protocol === "https:" ? 443 : 80),
path: u.pathname + u.search,
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${resolved.apiKey}`,
},
timeout: 90000,
}, res => {
let chunks = [];
res.on("data", c => chunks.push(c));
res.on("end", () => {
try {
const text = Buffer.concat(chunks).toString();
const parsed = JSON.parse(text);
const content = parsed.choices?.[0]?.message?.content
|| parsed.content?.[0]?.text
|| parsed.candidates?.[0]?.content?.parts?.[0]?.text
|| "";
resolve(content.trim());
} catch (e) {
reject(new Error("AI 响应解析失败: " + e.message));
}
});
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("AI 请求超时")); });
req.write(bodyStr);
req.end();
});
}
async function runAlertRCA(alertId, providerConfigsOverride) {
const start = Date.now();
const results = { toolInvocations: [] };
function resetCaches() {
try { deviceStore._resetForTest?.(); } catch {}
try { alertRecordStore._resetForTest?.(); } catch {}
try { inspectionResultStore._resetForTest?.(); } catch {}
try { alertRuleStore._resetForTest?.(); } catch {}
}
// 1. 找告警
resetCaches();
const alerts = alertRecordStore.listAlerts(readFn, { limit: 500 });
const alert = alerts.find(a => a.id === alertId);
if (!alert) return { success: false, error: "告警不存在", durationMs: 0, toolInvocations: [] };
// 2. 找 hostConfig
let hostConfig = null;
if (_hostConfigs && _hostConfigs[alert.device_id]) {
const hc = _hostConfigs[alert.device_id];
hostConfig = {
hostname: hc.host || hc.hostname,
port: hc.port || 22,
username: hc.username,
password: hc.password,
privateKeyContent: hc.privateKey || hc.privateKeyContent,
passphrase: hc.passphrase,
};
}
// 3. 找 catalog_id + 巡检 metrics
let catalogId = "";
const extMap = deviceStore.getAllExtensions(readFn);
if (extMap[alert.device_id]?.catalog_id) catalogId = extMap[alert.device_id].catalog_id;
let metrics = {};
const latest = inspectionResultStore.getLatestAll(readFn);
const result = latest.find(r => r?.device_id === alert.device_id);
if (result) metrics = result.metrics || {};
const alertInfo = {
device_name: alert.device_name,
device_id: alert.device_id,
level: alert.level,
metric: alert.metric,
metric_value: alert.metric_value,
threshold: alert.threshold,
rule_name: alert.rule_name,
triggered_at: alert.triggered_at,
message: alert.message,
catalog_id: catalogId || "unknown",
online: !!result?.online,
};
const providerConfigs = providerConfigsOverride || bridge.providerConfigs;
// 4. Phase 1 — AI 规划诊断命令
let phase1Plan = { commands: [] };
try {
const phase1Input = JSON.stringify({
alert: alertInfo,
metrics,
catalog_id: catalogId,
device_context: `设备名: ${alert.device_name}, 设备ID: ${alert.device_id}`,
});
const phase1Text = await _callAiForRCA(providerConfigs, PHASE1_SYSTEM, phase1Input, 1000);
const js = _extractJson(phase1Text);
phase1Plan = js;
console.log("[RCA] Phase 1 规划:", phase1Plan.commands?.length, "条命令");
} catch (e) {
console.warn("[RCA] Phase 1 失败,跳过 SSH 诊断:", e.message);
results.toolInvocations.push({ tool: "phase1_planning", ok: false, durationMs: Date.now() - start, error: e.message });
}
// 5. SSH 执行诊断命令(仅当 hostConfig 可用时)
const evidence = [];
let allCmdOutputs = "";
if (hostConfig && phase1Plan.commands?.length) {
console.log("[RCA] 开始 SSH 诊断,目标:", hostConfig.hostname, "命令数:", phase1Plan.commands.length);
for (const cmd of phase1Plan.commands) {
const cmdStart = Date.now();
try {
const sshRes = await execSshCommand({
host: hostConfig.hostname,
port: hostConfig.port || 22,
username: hostConfig.username,
password: hostConfig.password,
privateKey: hostConfig.privateKeyContent,
passphrase: hostConfig.passphrase,
}, cmd.command, { connectTimeoutMs: 15000, execTimeoutMs: 20000 });
const output = (sshRes || "").toString();
const truncated = output.length > 2000 ? output.slice(-2000) : output;
allCmdOutputs += `\n### ${cmd.command}\n${truncated}\n`;
evidence.push({
tool: "ssh_command",
summary: `${cmd.command}: ${output.length} 字节`,
keyFindings: [truncated.slice(0, 300)],
});
results.toolInvocations.push({ tool: cmd.command, ok: true, durationMs: Date.now() - cmdStart });
} catch (e) {
results.toolInvocations.push({ tool: cmd.command, ok: false, durationMs: Date.now() - cmdStart, error: e.message });
evidence.push({ tool: "ssh_command", summary: `${cmd.command}: 失败 - ${e.message}`, keyFindings: [] });
}
}
} else if (!hostConfig) {
console.warn("[RCA] 无 hostConfig,跳过 SSH 诊断");
}
// 6. Phase 2 — AI 综合根因分析
try {
const phase2Input = JSON.stringify({
alert: alertInfo,
metrics,
catalog_id: catalogId,
command_outputs: allCmdOutputs.slice(0, 8000),
});
const phase2Text = await _callAiForRCA(providerConfigs, PHASE2_SYSTEM, phase2Input, 2000);
const phase2Json = _extractJson(phase2Text);
const rcaPayload = JSON.stringify({
__rca_json: true,
generated_at: Date.now(),
source: "ai_full",
summary: phase2Json.summary,
confidence: phase2Json.confidence,
rootCauses: phase2Json.rootCauses,
evidence,
recommendations: phase2Json.recommendations,
});
// 7. 写回告警 note
try {
resetCaches();
const rawAlerts = readFn("netcatty_inspection_alert_records_v1");
let alertArr = typeof rawAlerts === "string" ? JSON.parse(rawAlerts || "[]") : (rawAlerts || []);
alertArr = alertArr.map(a => a.id === alertId ? { ...a, note: "[RCA_JSON]" + rcaPayload } : a);
writeFn("netcatty_inspection_alert_records_v1", JSON.stringify(alertArr));
console.log("[RCA] 结果已写回告警 note");
} catch (e) {
console.error("[RCA] 写回告警失败:", e.message);
}
return {
success: true,
rca: {
alertId,
deviceId: alert.device_id,
deviceName: alert.device_name,
confidence: phase2Json.confidence ?? 0,
rootCauses: phase2Json.rootCauses || [],
evidence,
recommendations: phase2Json.recommendations || [],
summary: phase2Json.summary || "",
},
source: "ai",
durationMs: Date.now() - start,
toolInvocations: results.toolInvocations,
};
} catch (e) {
console.error("[RCA] Phase 2 综合失败:", e.message);
return {
success: false,
error: "Phase 2 AI 综合失败: " + e.message,
rca: null,
durationMs: Date.now() - start,
toolInvocations: results.toolInvocations,
};
}
}
// 注册为 IPC handler(Electron 本地备用,主进程直接调用)
ipcMain.handle("inspection:runAlertRCA", async (_e, alertId) => {
try {
const result = await runAlertRCA(alertId);
return { ok: result.success, ...result };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
});
// 导出给 remoteHttpServer 直接调用(同一个主进程,共享 _hostConfigs 和存储)
module.exports.runAlertRCA = runAlertRCA;
// ---------- 巡检报告快照 ----------
// runReportSnapshot: renderer 侧解密 vault 构建 hostConfig,传入主进程执行报告命令组
// payload: {
// deviceTargets: [
// { device_id, hostname, username, password?, privateKeyContent?, passphrase?, port?, catalog_id? }
// ],
// trigger: "manual" | "scheduled"
// }
// ERROR_PATTERNS + stripControl 抽成模块内共享
// 注意:ERROR_PATTERNS 必须精确匹配真正的运行时命令失败标记,
// 不能用 "failed" "fail" 之类单词,因为正常输出里会大量包含
// 不能把 command not found 算失败 —— 巡检命令都有 2>/dev/null || echo 'no_xxx' 兜底,
// 跨平台混跑(unknown fallback 组)时没被 2>/dev/null 吞掉的 command not found 不算真正问题
const _SNAPSHOT_ERROR_PATTERNS = /^.*\b(unrecognized command|invalid input|wrong parameter|error command|unknown command|Unknown command)\b.*$|^Error.*$|% Error:|Command failed|timed out|ETIMEDOUT|ECONNREFUSED|authentication failed/i;
function _stripControl(text) {
return (text || "")
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
.replace(/\x1B\][^\x07\x1B]*(\x07|\x1B\\)/g, "")
.replace(/\x1B[PX][^\x1B]*\x1B\\/g, "")
.replace(/\x1B[@-_][0-9;]*[ -\/]*[@-~]/g, "")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "")
.replace(/\r/g, "")
.trim();
}
// 过滤厂商 banner URL / 欢迎语 / 版权等 AI 不需要的噪音
// 麒麟/华为/Ubuntu/Cisco/RedHat 登录 banner 里带的 Management URL 一律删除
function _stripVendorNoise(text) {
if (!text) return text;
let out = text;
// 删除 banner 里的厂商 URL(http/https + www. + 厂商域名)
out = out.replace(/https?:\/\/[^\s|,]+/g, "");
// 单独处理 www.kylinos / www.huawei / www.cisco / www.redhat / www.ubuntu 等没带 http 的
out = out.replace(/\bwww\.[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/gi, "");
// 删除常见 banner 标语行(整行删除,不影响命令输出)
out = out.split("\n")
.filter(line => {
const t = line.trim();
if (!t) return true;
// 麒麟 / Ubuntu / RedHat 欢迎 banner
if (/Welcome to (Kylin|Ubuntu|CentOS|Red Hat|RedHat|Debian|SUSE|openSUSE|Fedora|AlmaLinux|Rocky Linux)/i.test(t)) return false;
// 华为/H3C 登录 banner 第一行("Huawei Versatile Routing Platform Software" / "H3C Comware Platform Software")
if (/^(Huawei|H3C|Cisco|Ruijie|ZTE|锐捷|中兴|华为)/.test(t) && /(Platform|Software|Operating System|Comware|VRP)/i.test(t)) return false;
// Management URL 行("Management: http://..." / "Management IP: ..." 但 URL 已在上文删过,这里删整行)
if (/^\s*\*?\s*Management\s*[::]/i.test(t)) return false;
// * Copyright / * Trademark / * All Rights Reserved / * For Internal Use Only
if (/^\s*\*?\s*(Copyright|Trademark|All Rights Reserved|For Internal Use|Unauthorized)/i.test(t)) return false;
// 纯 * 号行(banner 装饰线)
if (/^\s*\*{5,}\s*$/.test(t)) return false;
return true;
})
.join("\n");
// 多个空行压缩成最多一个
out = out.replace(/\n{3,}/g, "\n\n");
return out.trim();
}
// 内部函数:执行一次快照(IPC handler + reportScheduler 共用)
async function _executeSnapshot(deviceTargets, trigger = "manual") {
const t0 = Date.now();
const DEBUG = process.env.INSPECTION_DEBUG === "1";
// 打印唯一调用栈前几帧 —— 一眼看出是谁调的
const stack = new Error().stack?.split("\n").slice(2, 5).join("\n ") || "";
console.error(`[EXEC_SNAPSHOT] ════════════════════════════════════`);
console.error(`[EXEC_SNAPSHOT] 路径: ${trigger} | 设备数: ${deviceTargets.length}`);
console.error(`[EXEC_SNAPSHOT] 调用栈:\n ${stack}`);
console.error(`[EXEC_SNAPSHOT] ════════════════════════════════════`);
console.log("[Inspection:_executeSnapshot] 目标设备数:", deviceTargets.length, "trigger:", trigger);
const allDevices = [];
for (const target of deviceTargets) {
// catalogId: payload 有就用,没有从 device extension 兜底
let catalogId = target.catalog_id;
if (!catalogId) {
try {
const ext = deviceStore.getExtension(readFn, target.device_id);
catalogId = ext?.catalog_id || undefined;
} catch (_) {}
}
const cmds = reportCommands.getReportCommands(catalogId);
const catalogCmdDefs = cmds.commands; // 保留原始对象(含 altCmd / purpose)
// 传给 execSshCommands 的纯字符串数组(preCmd 单独标记)
const orderedCmdStrings = [];
if (cmds.preCmd) orderedCmdStrings.push(cmds.preCmd);
orderedCmdStrings.push(...catalogCmdDefs.map(c => c.cmd));
const sshConfig = {
host: target.hostname,
port: target.port || 22,
username: target.username,
password: target.password,
privateKey: target.privateKeyContent,
passphrase: target.passphrase,
keepaliveCountMax: 0,
keepaliveInterval: 0,
};
const deviceCmds = [];
let deviceOk = true;
// 先把所有主命令跑一遍
let sshResult;
try {
sshResult = await execSshCommands(sshConfig, orderedCmdStrings, {
connectTimeoutMs: 15000,
execTimeoutMs: 30000,
});
} catch (e) {
// 整台设备 SSH 失败
deviceOk = false;
for (const cmd of orderedCmdStrings) {
deviceCmds.push({
cmd, purpose: "", ok: false, duration_ms: 0,
stdout: "", stderr: String(e?.message || e),
});
}
allDevices.push({
device_id: target.device_id,
hostname: target.hostname,
catalog_id: catalogId || "unknown",
ok: false,
duration_ms: 0,
commands: deviceCmds,
});
continue;
}
const results = sshResult?.results || [];
// 逐命令评估 —— 失败的如果有 altCmd 就单独重试一次
for (let i = 0; i < orderedCmdStrings.length; i++) {
const cmd = orderedCmdStrings[i];
const isPreCmd = cmds.preCmd && cmd === cmds.preCmd;
const def = isPreCmd ? null : catalogCmdDefs.find(c => c.cmd === cmd);
const purpose = isPreCmd ? "关闭分页(前置)" : (def?.purpose || "");
const r = results[i] || { output: "", ok: true, durationMs: 0 };
let rawOutput = _stripVendorNoise(_stripControl(r.output || ""));
let isOk = !_SNAPSHOT_ERROR_PATTERNS.test(rawOutput) && (r.ok !== false);
let finalCmd = cmd;
// 主命令失败 → 有 altCmd → 重试一次(仅针对 catalog 业务命令,preCmd 不重试)
if (!isOk && !isPreCmd && def?.altCmd) {
if (DEBUG) console.log(`[Inspection:_executeSnapshot] 命令失败,尝试 altCmd: ${cmd} → ${def.altCmd}`);
try {
const altResult = await execSshCommand(sshConfig, def.altCmd, {
connectTimeoutMs: 15000,
execTimeoutMs: 20000,
});
const altOutput = _stripVendorNoise(_stripControl(altResult?.output || ""));
const altOk = !_SNAPSHOT_ERROR_PATTERNS.test(altOutput) && (altResult?.ok !== false);
if (altOk) {
rawOutput = altOutput;
isOk = true;
finalCmd = def.altCmd;
// duration 累计:主命令 + altCmd
}
} catch (altErr) {
if (DEBUG) console.log(`[Inspection:_executeSnapshot] altCmd 也失败: ${altErr?.message}`);
}
}
if (!isOk) deviceOk = false;
deviceCmds.push({
cmd: finalCmd,
purpose,
ok: isOk,
duration_ms: r.durationMs || 0,
stdout: rawOutput,
stderr: "",
});
}
allDevices.push({
device_id: target.device_id,
hostname: target.hostname,
catalog_id: catalogId || "unknown",
ok: deviceOk,
duration_ms: 0,
commands: deviceCmds,
});
console.log("[Inspection:_executeSnapshot] 设备", target.hostname,
"→ ok:", deviceOk, "命令数:", deviceCmds.length);
}
const totalDuration = Date.now() - t0;
for (const dev of allDevices) {
const lastCmd = dev.commands[dev.commands.length - 1];
dev.duration_ms = lastCmd?.duration_ms || 0;
}
const snapshot = reportStore.addSnapshot(readFn, writeFn, {
timestamp: Date.now(),
duration_ms: totalDuration,
trigger,
devices: allDevices,
});
console.log("[Inspection:_executeSnapshot] 完成,耗时:", totalDuration, "ms, 快照 id:", snapshot.id);
// 广播快照完成事件 —— renderer 即使切换页面也能收到,切回后自动刷新列表
try {
const { webContents } = require("electron");
for (const wc of webContents.getAllWebContents()) {
wc.send("inspection:reportSnapshotCompleted", {
snapshotId: snapshot.id,
timestamp: snapshot.timestamp,
trigger: snapshot.trigger,
deviceCount: snapshot.devices.length,
okCount: snapshot.devices.filter(d => d.ok).length,
duration_ms: totalDuration,
});
}
} catch (_) {}
// 自动触发 AI 分析 —— 不阻塞快照返回,后台独立执行
const bridge = require("../aiBridge.cjs");
const activeProviders = bridge.providerConfigs || [];
const defaultProvider = activeProviders.find(p => p?.selected) || activeProviders[0];
const defaultProviderId = defaultProvider?.id;
setImmediate(() => {
// 直接调用原来 handler 里的完整逻辑(避免重复代码)
_runReportAiSummaryInternal(snapshot.id, defaultProviderId)
.catch(err => console.error("[Inspection] 自动 AI 分析失败:", err?.message || String(err)));
});
return snapshot;
}
/** AI 内部执行函数 —— 自动触发和 IPC handler 共用同一套逻辑 */
async function _runReportAiSummaryInternal(snapshotId, providerId) {
console.log("[ReportAiSummaryInternal] 🚀 开始执行 snapshotId=", snapshotId, "providerId=", providerId);
const snapshot = reportStore.getSnapshot(readFn, snapshotId);
if (!snapshot) {
console.log("[ReportAiSummaryInternal] ❌ 快照不存在:", snapshotId);
return;
}
console.log("[ReportAiSummaryInternal] ✅ 快照找到, devices=", snapshot.devices?.length || 0);
reportStore.setSnapshotAiStatus(readFn, writeFn, snapshotId, "running");
_broadcastReportAiStatus(snapshotId, "running");
try {
const bridge = require("../aiBridge.cjs");
const activeProviders = bridge.providerConfigs;
console.log("[ReportAiSummaryInternal] activeProviders=", activeProviders.length);
const provider = activeProviders.find(p => p.id === providerId)
|| activeProviders.find(p => p.enabled !== false);
if (!provider) throw new Error("AI provider 未配置");
console.log("[ReportAiSummaryInternal] ✅ provider=", provider.id, provider.name);
const resolved = bridge.resolveProviderApiKey(provider.id);
if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
console.log("[ReportAiSummaryInternal] ✅ API key 已解密, 长度=", resolved.apiKey.length);
const userContent = _buildSnapshotPrompt(snapshot);
// 兜底:如果压缩后仍超过 50KB,硬截断设备数量
let finalContent = userContent;
if (userContent.length > 50000) {
try {
const parsed = JSON.parse(userContent);
if (parsed.online && parsed.online.length > 12) {
parsed.online = parsed.online.slice(0, 12);
parsed.summary.online_n = 12;
finalContent = JSON.stringify(parsed);
}
} catch {}
}
const url = _buildAiUrl(provider);
const headers = { "Content-Type": "application/json", "Authorization": "Bearer __IPC_SECURED__" };
const body = JSON.stringify(_buildAiBody(provider, finalContent));
console.log("[ReportAiSummaryInternal] 准备请求 AI, url=", url?.slice(0, 80), "body.length=", body.length);
const patched = bridge.injectApiKeyIntoRequest(url, headers, provider.id);
const skipTLS = bridge.shouldSkipTLSVerify(provider.id);
console.log("[ReportAiSummaryInternal] 发送 HTTP 请求...");
const result = await _doHttpFetch(patched.url, patched.headers, body, { skipTLS, timeoutMs: 60000 });
console.log("[ReportAiSummaryInternal] HTTP 返回 status=", result.status, "ok=", result.ok, "error=", result.error);
if (!result.ok) throw new Error(result.error || `HTTP ${result.status}`);
const raw = _parseAiResponse(provider, result.data);
console.log("[ReportAiSummaryInternal] 解析 AI 响应长度=", raw?.length || 0);
const parsed = _extractJson(raw);
const summary = parsed || { health_summary: raw, offline_devices: [], hidden_risks: [], recommendations: [] };
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, summary);
_broadcastReportAiStatus(snapshotId, "done");
console.log("[ReportAiSummaryInternal] ✅ AI 分析完成!");
} catch (err) {
const msg = err?.message || String(err);
console.error("[runReportAiSummary] 失败:", msg);
console.error("[runReportAiSummary] stack:", err?.stack?.split('\n').slice(0, 5).join('\n'));
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, null, msg);
_broadcastReportAiStatus(snapshotId, "failed", msg);
}
}
ipcMain.handle("inspection:runReportSnapshot", async (_e, payload) => {
const { deviceTargets = [], trigger = "manual" } = payload || {};
// 防重入:快照正在执行中就拒绝(用户连点按钮或定时 tick 并发)
if (_isSnapshotRunning) {
console.warn("[Inspection:runReportSnapshot] 快照正在执行中,拒绝并发请求");
return { ok: false, error: "快照正在执行中,请稍候" };
}
_isSnapshotRunning = true;
// 广播 running 状态(页面切换后回来的 renderer 也能收到)
try {
const { webContents } = require("electron");
for (const wc of webContents.getAllWebContents()) {
wc.send("inspection:reportSnapshotStatusChanged", { running: true });
}
} catch (_) {}
try {
const snapshot = await _executeSnapshot(deviceTargets, trigger);
return { ok: true, snapshot };
} catch (e) {
console.error("[Inspection:runReportSnapshot] 失败:", e);
return { ok: false, error: e?.message || String(e) };
} finally {
_isSnapshotRunning = false;
// 手动巡检也更新 lastRunAt —— 避免手动跑完后定时 tick 还到点触发
try {
const cfg = reportScheduleStore.getSchedule(readFn);
reportScheduleStore.saveSchedule(writeFn, { ...cfg, lastRunAt: Date.now() });
} catch (_) {}
try {
const { webContents } = require("electron");
for (const wc of webContents.getAllWebContents()) {
wc.send("inspection:reportSnapshotStatusChanged", { running: false });
}
} catch (_) {}
}
});
ipcMain.handle("inspection:isReportSnapshotRunning", () => {
return { running: _isSnapshotRunning };
});
ipcMain.handle("inspection:listReportSnapshots", (_e, limit) => {
return reportStore.listSnapshots(readFn, limit || 50);
});
ipcMain.handle("inspection:deleteReportSnapshot", (_e, id) => {
return reportStore.deleteSnapshot(readFn, writeFn, id);
});
ipcMain.handle("inspection:clearReportSnapshots", () => {
reportStore.clearAll(readFn, writeFn);
return { ok: true };
});
ipcMain.handle("inspection:saveReportAiSummary", (_e, { snapshotId, aiSummary }) => {
if (!snapshotId) return { ok: false, error: "snapshotId required" };
const ok = reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, aiSummary);
return { ok };
});
// 拓扑位置持久化(Electron 拖拽保存 → 远程浏览器可同步读取)
ipcMain.handle("inspection:saveTopologyPositions", (_e, positions) => {
if (!positions || typeof positions !== "object") return { ok: false };
try {
if (Object.keys(positions).length === 0) {
const existing = readStorage("_topology_positions");
if (existing && typeof existing === "object" && Object.keys(existing).length > 0) {
console.info("[inspection] saveTopologyPositions skip empty");
return { ok: true, skipped: true };
}
}
writeStorage("_topology_positions", positions);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message };
}
});
ipcMain.handle("inspection:getTopologyPositions", () => {
try {
return readStorage("_topology_positions") || {};
} catch { return {}; }
});
// 监控大屏标题同步(Electron localStorage → 远程浏览器可读)
ipcMain.handle("inspection:saveDashboardTitle", (_e, title) => {
if (!title || typeof title !== "string") return { ok: false };
try {
const RENDERER_DEFAULT = "云启Net智能巡检监测系统";
const existing = readStorage("_dashboard_title");
if (title === RENDERER_DEFAULT && existing && existing !== RENDERER_DEFAULT) {
console.info("[inspection] saveDashboardTitle skip default:", title, "vs:", existing);
return { ok: true, skipped: true };
}
writeStorage("_dashboard_title", title);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message };
}
});
// 产品名称同步(侧边栏显示名,远程浏览器读)
ipcMain.handle("inspection:saveProductName", (_e, name) => {
if (!name || typeof name !== "string") return { ok: false };
try {
const RENDERER_DEFAULT = "云启智能运维平台";
const existing = readStorage("_product_name");
if (name === RENDERER_DEFAULT && existing && existing !== RENDERER_DEFAULT) {
console.info("[inspection] saveProductName skip default:", name, "vs:", existing);
return { ok: true, skipped: true };
}
writeStorage("_product_name", name);
return { ok: true };
} catch (e) { return { ok: false, error: e?.message }; }
});
ipcMain.handle("inspection:getProductName", () => {
try { return readStorage("_product_name") || ""; } catch { return ""; }
});
// 报告产品名同步
ipcMain.handle("inspection:saveReportProductName", (_e, name) => {
if (typeof name !== "string") return { ok: false };
try {
if ((name === "" || name === null) && name !== undefined) {
const existing = readStorage("_report_product_name");
if (existing && existing.length > 0) {
console.info("[inspection] saveReportProductName skip empty, existing:", existing);
return { ok: true, skipped: true };
}
}
writeStorage("_report_product_name", name);
return { ok: true };
} catch (e) { return { ok: false, error: e?.message }; }
});
ipcMain.handle("inspection:getReportProductName", () => {
try { return readStorage("_report_product_name") || ""; } catch { return ""; }
});
// 完整 vault hosts 同步(前端加载后发给主进程,远程浏览器 API 读)
ipcMain.handle("inspection:saveVaultHosts", (_e, hosts) => {
if (!Array.isArray(hosts)) { console.info("[inspection] saveVaultHosts called but not array:", typeof hosts); return { ok: false }; }
try {
console.info("[inspection] saveVaultHosts called, count:", hosts.length, "sample keys:", hosts[0] ? Object.keys(hosts[0]).join(',') : 'empty');
// 守卫:renderer 首次启动 Local Storage 空时会传 [],
// 但 inspectionStorage 可能已有完整数据(含凭证)→ 不要覆盖!
if (hosts.length === 0) {
const existing = readStorage("_vault_hosts");
if (Array.isArray(existing) && existing.length > 0) {
console.info("[inspection] saveVaultHosts 跳过空覆盖");
return { ok: true, count: existing.length, skipped: true };
}
}
// 保存完整 hosts(含 username/password/port/identityFileId 等凭证字段)
const mapped = hosts.map(h => ({
id: h.id,
label: h.label || h.name || h.hostname || "",
hostname: h.hostname || h.name || "",
port: h.port,
username: h.username,
password: h.password,
identityFileId: h.identityFileId,
privateKey: h.privateKey,
passphrase: h.passphrase,
tags: h.tags || [],
os: h.os || "linux",
type: h.type || "",
distro: h.distro || h.catalogId || "",
group: h.group || "",
legacyAlgorithms: h.legacyAlgorithms,
}));
writeStorage("_vault_hosts", mapped);
return { ok: true, count: mapped.length };
} catch (e) {
return { ok: false, error: e?.message };
}
});
ipcMain.handle("inspection:getVaultHosts", () => {
try {
return readStorage("_vault_hosts") || [];
} catch { return []; }
});
// Vault 通用数据同步(renderer localStorage → 主进程 inspection-data.json → 远程 HTTP API 读)
// 覆盖所有子页面:钥匙串/代理/端口转发/脚本/笔记/已知主机/日志/分组/身份
const _VAULT_DATA_KEYS = ["hosts", "keys", "proxies", "port_forwards", "snippets", "notes", "known_hosts", "connection_logs", "groups", "identities"];
_VAULT_DATA_KEYS.forEach(vk => {
const sk = "_vault_" + vk;
// renderer → 主进程
// 守卫:renderer 首次启动 Local Storage 空时会传 [],
// 但 inspectionStorage 可能已有从开发版拷过来的完整数据 → 不要覆盖!
ipcMain.handle("inspection:syncVault:" + vk, (_e, data) => {
try {
const incomingEmpty = data === null || data === undefined
|| (Array.isArray(data) && data.length === 0)
|| (typeof data === "object" && !Array.isArray(data) && Object.keys(data).length === 0);
const existing = readStorage(sk);
const existingNonEmpty = existing !== null && existing !== undefined
&& ((Array.isArray(existing) && existing.length > 0)
|| (typeof existing === "object" && !Array.isArray(existing) && Object.keys(existing).length > 0));
if (incomingEmpty && existingNonEmpty) {
console.info(`[inspection] syncVault:${vk} 跳过:renderer 传空,但 inspectionStorage 已有 ${(Array.isArray(existing) ? existing.length : Object.keys(existing).length)} 条`);
return { ok: true, skipped: true };
}
writeStorage(sk, data ?? (Array.isArray(data) ? [] : {}));
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message };
}
});
// 主进程内部读取(remoteHttpServer 也会直接 require inspectionStorage 读)
ipcMain.handle("inspection:getVault:" + vk, () => {
try { return readStorage(sk); } catch { return null; }
});
});
// 拓扑连线同步(Electron localStorage → 主进程 → 远程浏览器 API 读)
ipcMain.handle("inspection:getTopologyLinks", () => {
try { const v = readStorage("_topology_links"); return Array.isArray(v) ? v : []; } catch { return []; }
});
ipcMain.handle("inspection:saveTopologyLinks", (_e, links) => {
try {
const arr = Array.isArray(links) ? links : [];
if (arr.length === 0) {
const existing = readStorage("_topology_links");
if (Array.isArray(existing) && existing.length > 0) {
console.info("[inspection] saveTopologyLinks skip empty");
return { ok: true, count: existing.length, skipped: true };
}
}
writeStorage("_topology_links", arr);
return { ok: true, count: arr.length };
} catch (e) {
return { ok: false, error: e?.message };
}
});
ipcMain.handle("inspection:getDashboardTitle", () => {
try {
return readStorage("_dashboard_title") || "";
} catch { return ""; }
});
ipcMain.handle("inspection:clearReportAiSummary", (_e, { snapshotId }) => {
if (!snapshotId) return { ok: false, error: "snapshotId required" };
const ok = reportStore.clearSnapshotAiSummary(readFn, writeFn, snapshotId);
return { ok };
});
ipcMain.handle("inspection:runReportAiSummary", async (_e, { snapshotId, providerId }) => {
if (!snapshotId) return { ok: false, error: "snapshotId required" };
console.log("[ReportAiSummary] ⚡ 收到 IPC 请求 snapshotId=", snapshotId, "providerId=", providerId);
console.log("[ReportAiSummary] providerConfigs=", require("../aiBridge.cjs").aiBridgeContext?.providerConfigs?.length || 0, "个");
// 后台独立执行 —— 不阻塞 IPC 返回
setImmediate(() => {
console.log("[ReportAiSummary] ⏱️ setImmediate 触发执行...");
_runReportAiSummaryInternal(snapshotId, providerId)
.catch(err => console.error("[runReportAiSummary] 异常:", err?.message || String(err)));
});
return { ok: true };
});
// ---------- 巡检报告调度配置 ----------
// 注意:定时快照执行需要 vault SSH 配置(仅 renderer 可解密),
// reportScheduler 目前只负责日报告 HH:MM 定时聚合;
// 定时快照由 renderer 侧(ReportPage useEffect + setInterval)调 runReportSnapshot 传入解密后的 hostConfig
ipcMain.handle("inspection:getReportSchedule", () => {
return reportScheduleStore.getSchedule(readFn);
});
ipcMain.handle("inspection:saveReportSchedule", (_e, config) => {
const saved = reportScheduleStore.saveSchedule(writeFn, config);
// 配置变更后如果 scheduler 没启动但 enabled=true,则启动(负责 dailyReport 定时聚合 + 主进程定时快照)
if (saved.enabled && !reportScheduler.isRunning()) {
reportScheduler.start({
readStorage: options.readStorage,
writeStorage: options.writeStorage,
runSnapshotFn: (deviceIds, trigger) => _runScheduledSnapshot(deviceIds, trigger),
getAllDeviceIdsFn: () => Object.keys(_hostConfigs || {}),
});
}
return saved;
});
ipcMain.handle("inspection:toggleReportScheduler", (_e, enabled) => {
const cfg = reportScheduleStore.getSchedule(readFn);
cfg.enabled = !!enabled;
reportScheduleStore.saveSchedule(writeFn, cfg);
if (cfg.enabled && !reportScheduler.isRunning()) {
reportScheduler.start({
readStorage: options.readStorage,
writeStorage: options.writeStorage,
runSnapshotFn: (deviceIds, trigger) => _runScheduledSnapshot(deviceIds, trigger),
getAllDeviceIdsFn: () => Object.keys(_hostConfigs || {}),
});
} else if (!cfg.enabled && reportScheduler.isRunning()) {
reportScheduler.stop();
}
return { ok: true, running: reportScheduler.isRunning() };
});
ipcMain.handle("inspection:reportSchedulerStatus", () => {
return {
running: reportScheduler.isRunning(),
schedule: reportScheduleStore.getSchedule(readFn),
};
});
// ---------- 日运行报告 ----------
ipcMain.handle("inspection:listDailyReports", (_e, limit) => {
return dailyReportStore.listDailyReports(readFn, limit || 30);
});
ipcMain.handle("inspection:generateDailyReport", async (_e, options) => {
const { mode = "provisional", trigger = "manual" } = options || {};
try {
const report = await reportScheduler.triggerDailyReport(readFn, writeFn, { mode, trigger });
return { ok: true, report };
} catch (e) {
console.error("[Inspection:generateDailyReport] 失败:", e);
return { ok: false, error: e?.message || String(e) };
}
});
ipcMain.handle("inspection:deleteDailyReport", (_e, id) => {
return dailyReportStore.deleteDailyReport(readFn, writeFn, id);
});
ipcMain.handle("inspection:clearDailyReports", () => {
dailyReportStore.clearAll(readFn, writeFn);
return { ok: true };
});
// ---------- PDF 导出 ----------
ipcMain.handle("inspection:exportReportSnapshotPdf", async (_e, payload) => {
try {
console.log("[ExportPDF] 开始 payload=", payload);
const snapshotId = typeof payload === "string" ? payload : payload?.snapshotId;
const aiSummary = typeof payload === "object" && payload !== null ? payload.aiSummary : undefined;
const customPath = typeof payload === "object" && payload !== null ? payload.customPath : undefined;
const systemName = (typeof payload === "object" && payload !== null ? payload.systemName : undefined) || _readProductName(readFn);
console.log("[ExportPDF] snapshotId=", snapshotId, "customPath=", customPath, "systemName=", systemName);
const snap = reportStore.getSnapshot(readFn, snapshotId);
if (!snap) {
console.log("[ExportPDF] 快照不存在 snapshotId=", snapshotId);
return { ok: false, error: "快照不存在" };
}
console.log("[ExportPDF] 找到快照 devices=", snap.devices?.length, "aiSummary存在=", !!aiSummary);
const app = require("electron").app;
const filePath = await reportExport.exportSnapshotPdf(app, snap, aiSummary, customPath, systemName);
console.log("[ExportPDF] 完成 filePath=", filePath);
return { ok: true, filePath };
} catch (e) {
console.error("[ExportPDF] 失败:", e);
console.error("[ExportPDF] 堆栈:", e?.stack);
return { ok: false, error: e?.message || String(e) };
}
});
ipcMain.handle("inspection:previewReportSnapshotPdf", async (_e, { snapshotId, systemName }) => {
try {
console.log("[PreviewPDF] 开始 snapshotId=", snapshotId, "systemName=", systemName);
const snap = reportStore.getSnapshot(readFn, snapshotId);
if (!snap) return { ok: false, error: "快照不存在" };
const app = require("electron").app;
const aiSummary = snap.ai_summary || undefined;
const effectiveName = systemName || _readProductName(readFn);
console.log("[PreviewPDF] aiSummary 存在:", !!aiSummary, "effectiveName:", effectiveName);
const result = await reportExport.previewSnapshotPdf(app, snap, aiSummary, effectiveName);
console.log("[PreviewPDF] 完成 ok=", result.ok, "filePath=", result.filePath);
return result;
} catch (e) {
console.error("[PreviewPDF] 失败:", e);
console.error("[PreviewPDF] 堆栈:", e?.stack);
return { ok: false, error: e?.message || String(e) };
}
});
ipcMain.handle("inspection:previewDailyReportPdf", async (_e, { reportId, systemName }) => {
try {
console.log("[PreviewDailyPDF] 开始 reportId=", reportId, "systemName=", systemName);
const reports = dailyReportStore.listDailyReports(readFn, 500);
const report = reports.find(r => r.id === reportId);
if (!report) return { ok: false, error: "日报告不存在" };
const app = require("electron").app;
const allSnaps = reportStore.listSnapshots(readFn, 500);
const pad = (n) => n.toString().padStart(2, "0");
const daySnaps = allSnaps.filter(s => {
const d = new Date(s.timestamp);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` === report.date;
});
const effectiveName = systemName || _readProductName(readFn);
console.log("[PreviewDailyPDF] report.date:", report.date, "daySnaps:", daySnaps.length, "effectiveName:", effectiveName);
const result = await reportExport.previewDailyReportPdf(app, report, daySnaps, effectiveName);
console.log("[PreviewDailyPDF] 完成 ok=", result.ok, "filePath=", result.filePath);
return result;
} catch (e) {
console.error("[PreviewDailyPDF] 失败:", e);
console.error("[PreviewDailyPDF] 堆栈:", e?.stack);
return { ok: false, error: e?.message || String(e) };
}
});
ipcMain.handle("inspection:exportDailyReportPdf", async (_e, payload) => {
try {
const reportId = typeof payload === "string" ? payload : payload?.reportId;
const customPath = typeof payload === "object" && payload !== null ? payload.customPath : undefined;
const systemName = (typeof payload === "object" && payload !== null ? payload.systemName : undefined) || _readProductName(readFn);
const reports = dailyReportStore.listDailyReports(readFn, 500);
const report = reports.find(r => r.id === reportId);
if (!report) return { ok: false, error: "日报告不存在" };
const app = require("electron").app;
const allSnaps = reportStore.listSnapshots(readFn, 500);
const pad = (n) => n.toString().padStart(2, "0");
const daySnaps = allSnaps.filter(s => {
const d = new Date(s.timestamp);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` === report.date;
});
const filePath = await reportExport.exportDailyReportPdf(app, report, customPath, daySnaps, systemName);
return { ok: true, filePath };
} catch (e) {
console.error("[Inspection:exportDailyReportPdf] 失败:", e);
return { ok: false, error: e?.message || String(e) };
}
});
// ---------- 告警记录 ----------
ipcMain.handle("inspection:listAlerts", (_e, options) => {
return alertRecordStore.listAlerts(readFn, options || {});
});
ipcMain.handle("inspection:getActiveAlerts", () => {
return alertRecordStore.getActiveAlerts(readFn);
});
ipcMain.handle("inspection:getAlertStats", () => {
return alertRecordStore.getAlertStats(readFn);
});
ipcMain.handle("inspection:acknowledgeAlert", (_e, alertId, operator) => {
const r = alertRecordStore.acknowledgeAlert(readFn, writeFn, alertId, operator);
broadcastAlertChanged();
return r;
});
ipcMain.handle("inspection:resolveAlert", (_e, alertId, reason, operator) => {
const r = alertRecordStore.resolveAlert(readFn, writeFn, alertId, reason, operator);
broadcastAlertChanged();
return r;
});
ipcMain.handle("inspection:closeAlert", (_e, alertId, note) => {
const r = alertRecordStore.closeAlert(readFn, writeFn, alertId, note);
broadcastAlertChanged();
return r;
});
ipcMain.handle("inspection:updateAlertNote", (_e, alertId, note) => {
return alertRecordStore.updateNote(readFn, writeFn, alertId, note);
});
// 存储前端传入的设备 SSH 配置(hostId → sshConfig)
let _hostConfigs = null;
let _loggedHostCreds = false;
// ---------- 调度器控制 ----------
ipcMain.handle("inspection:scheduler:start", (_e, options = {}) => {
// 前端可传入 { hostConfigs: { [hostId]: sshConfig } } 用于调度器执行 SSH 命令
if (options.hostConfigs) {
_hostConfigs = options.hostConfigs;
}
// 从 hostConfigs 提取 hostInfo(hostname/label)用于告警显示
const hostInfo = {};
if (_hostConfigs) {
for (const [hostId, config] of Object.entries(_hostConfigs)) {
hostInfo[hostId] = {
hostname: config.host || config.hostname || "",
label: config.label || "",
};
}
}
// 持久化 hostInfo 到 inspection-data.json,供远程 HTTP Server 读取补全设备名
try {
writeFn("_host_info_cache", hostInfo);
} catch {}
if (!_scheduler) {
_scheduler = new InspectionScheduler({
readFn,
writeFn,
execFn: async (hostId, command) => {
if (_hostConfigs && _hostConfigs[hostId]) {
const config = _hostConfigs[hostId];
if (!_loggedHostCreds) {
console.log("[Inspection] hostConfigs 凭据快照:", JSON.stringify(
Object.fromEntries(Object.entries(_hostConfigs).map(([id, c]) => [id, {
host: c.host,
username: c.username,
hasPassword: !!c.password,
hasPrivateKey: !!c.privateKey,
}]))
));
_loggedHostCreds = true;
}
const { execSshCommand: execCmd } = require("./inspectionExec.cjs");
return await execCmd(config, command, {
connectTimeoutMs: 20000,
execTimeoutMs: 30000,
});
}
const session = findSessionByHost(hostId);
if (session?.conn) {
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
return await executeBoundedSshCommand(session.conn, command, {
timeoutMs: 30000,
});
}
throw new Error(`无法执行巡检:未找到主机 ${hostId} 的 SSH 配置`);
},
// 批量执行多条命令(一次连接执行多条,华为/H3C 交换机采集器需要)
// 闭包和 execFn 一样——动态读 _hostConfigs 最新值
execCommandsFn: async (hostId, commands, options = {}) => {
if (_hostConfigs && _hostConfigs[hostId]) {
const config = _hostConfigs[hostId];
if (!_loggedHostCreds) {
console.log("[Inspection] execCommandsFn 凭据快照:", JSON.stringify(
Object.fromEntries(Object.entries(_hostConfigs).map(([id, c]) => [id, {
host: c.host,
username: c.username,
hasPassword: !!c.password,
hasPrivateKey: !!c.privateKey,
}]))
));
_loggedHostCreds = true;
}
const { execSshCommands } = require("./inspectionExec.cjs");
return await execSshCommands(config, commands, {
connectTimeoutMs: 20000,
execTimeoutMs: options.execTimeoutMs || 30000,
...options,
});
}
throw new Error(`无法批量执行巡检:未找到主机 ${hostId} 的 SSH 配置`);
},
pollIntervalMs: 30 * 1000,
concurrency: 5,
timeoutMs: 30 * 1000,
hostInfo,
onAlertChanged: broadcastAlertChanged,
});
} else {
// scheduler 已存在:更新 hostInfo(告警显示 device_name 用)
_scheduler._hostInfo = hostInfo;
console.log("[Inspection] 更新 hostConfigs:", Object.keys(_hostConfigs || {}).length, "台设备");
}
// start() 幂等:已 running 时直接返回。
// 不再每次 start 都 scanAllNow —— renderer 可能在多个页面 mount 时反复调 start
// (MonitoringPage、ReportPage、useReportAutoScheduler 各调一次),每次都 scanAllNow
// 会导致短时间内同一批设备被巡检 N 次。
// InspectionScheduler 本身有 30s tick,到点自然会把到期设备加入队列。
const wasRunning = _scheduler.isRunning();
_scheduler.start();
if (!wasRunning) {
console.log("[Inspection] scheduler 已启动,首次 hostConfigs=", Object.keys(_hostConfigs || {}).length);
}
return _scheduler.getStatus();
});
ipcMain.handle("inspection:scheduler:stop", () => {
if (_scheduler) {
_scheduler.stop();
return _scheduler.getStatus();
}
return { running: false };
});
ipcMain.handle("inspection:scheduler:status", () => {
return _scheduler ? _scheduler.getStatus() : { running: false };
});
// ─── 全局自动巡检一键启用/停止 ───
ipcMain.handle("inspection:scheduler:getAutoEnabled", () => {
const raw = readFn("inspection-auto-enabled");
return raw !== "false"; // 默认 true
});
ipcMain.handle("inspection:scheduler:setAutoEnabled", (_e, enabled) => {
const value = !!enabled;
writeFn("inspection-auto-enabled", value ? "true" : "false");
console.log(`[InspectionScheduler] 全局自动巡检 ${value ? "已启用" : "已停止"}`);
if (!value && _scheduler) {
// 停止时清空队列,避免积压
_scheduler._scanQueue = [];
}
return { enabled: value };
});
ipcMain.handle("inspection:scheduler:scanNow", (_e, hostId) => {
if (!_scheduler) return { error: "调度器未启动" };
return _scheduler.scanDeviceNow(hostId);
});
ipcMain.handle("inspection:scheduler:scanAllNow", () => {
if (!_scheduler) return { error: "调度器未启动" };
return _scheduler.scanAllNow();
});
console.log("[Inspection] IPC handlers 已注册");
// ─── Electron 启动时自动启动 reportScheduler ───
// 负责:per-device 定时快照 tick(主进程自身 SSH 执行) + dailyReport HH:MM 定时聚合
async function _runScheduledSnapshot(deviceIds, trigger = "scheduled") {
// 与 IPC 手动触发路径互斥:防止定时 tick 和用户手动按钮并发跑快照
if (_isSnapshotRunning) {
console.warn("[reportScheduler] _isSnapshotRunning=true(可能是手动触发在执行),跳过本次定时快照");
return;
}
if (!_hostConfigs || Object.keys(_hostConfigs).length === 0) {
console.warn("[reportScheduler] _hostConfigs 未初始化,定时快照跳过(需先打开监控页面让 renderer 同步 SSH 凭据到主进程)");
return;
}
const targets = [];
for (const hostId of deviceIds) {
const cfg = _hostConfigs[hostId];
if (!cfg) {
console.warn(`[reportScheduler] hostConfigs 中找不到 ${hostId},跳过`);
continue;
}
let catalogId = undefined;
try {
const ext = deviceStore.getExtension(readFn, hostId);
catalogId = ext?.catalog_id || cfg.catalog_id || undefined;
} catch (_) {
catalogId = cfg.catalog_id || undefined;
}
targets.push({
device_id: hostId,
hostname: cfg.host || cfg.hostname,
port: cfg.port || 22,
username: cfg.username,
password: cfg.password,
privateKeyContent: cfg.privateKey || cfg.privateKeyContent,
passphrase: cfg.passphrase,
catalog_id: catalogId,
});
}
if (targets.length === 0) {
console.warn("[reportScheduler] 无有效目标设备,跳过快照");
return;
}
console.log(`[reportScheduler] 主进程执行定时快照,目标 ${targets.length} 台`);
_isSnapshotRunning = true;
try {
await _executeSnapshot(targets, trigger);
} catch (e) {
console.error("[reportScheduler] 定时快照执行失败:", e?.message || String(e));
} finally {
_isSnapshotRunning = false;
}
}
// 自动启动 reportScheduler(Electron 进程生命周期内只启一次)
try {
const cfg = reportScheduleStore.getSchedule(readFn);
if (cfg?.enabled && !reportScheduler.isRunning()) {
reportScheduler.start({
readStorage: readFn,
writeStorage: writeFn,
runSnapshotFn: (deviceIds, trigger) => _runScheduledSnapshot(deviceIds, trigger),
getAllDeviceIdsFn: () => Object.keys(_hostConfigs || {}),
});
}
} catch (e) {
console.error("[Inspection] reportScheduler 自动启动失败:", e?.message || String(e));
}
return {
getScheduler: () => _scheduler,
createScheduler: (opts) => {
_scheduler = new InspectionScheduler({
readFn,
writeFn,
execFn: execCommand,
onAlertChanged: broadcastAlertChanged,
...opts,
});
return _scheduler;
},
};
}
module.exports = {
registerInspectionHandlers,
getLabels,
};