Files
SenMeshNet/electron/bridges/inspection/index.cjs
zhaolei 2561001dca
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
refactor: 重构巡检报告调度器,移除设备依赖并优化重试逻辑
- 重构调度器,改为注入获取设备ID的回调,移除硬依赖的deviceStore
- 调整前端设备配置同步逻辑,改为直接读取解密后的本地存储
- 修改重试间隔,从15秒改为3秒,首次同步改为立即执行
- 调整日志级别,优化错误信息输出
2026-10-15 14:15:16 +08:00

1513 lines
60 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 巡检报告标题专用)
// 优先级:netcatty_report_product_name_v1 > netcatty_product_name_v1 > 默认值
function _readProductName(readFn) {
try {
const raw = readFn("netcatty_report_product_name_v1");
const name = raw ? JSON.parse(raw) : null;
if (typeof name === "string" && name.trim()) return name.trim();
} catch (_) {}
try {
const raw = readFn("netcatty_product_name_v1");
const name = raw ? JSON.parse(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, 200),
});
} else {
const detailCmds = [];
let hasFail = false;
for (const c of cmds) {
if (!c.ok) {
hasFail = true;
detailCmds.push({
cmd: c.cmd, purpose: c.purpose, status: "fail",
output: (c.stdout || c.stderr || "").slice(0, 600),
});
continue;
}
const cls = _classifyCmd(c.cmd);
if (cls === "resource") {
detailCmds.push({ cmd: c.cmd, purpose: c.purpose, status: "ok", output: (c.stdout || "").slice(0, 400) });
} else if (cls === "log") {
detailCmds.push({ cmd: c.cmd, purpose: c.purpose, status: "ok", output: _truncateStdout(c.cmd, c.stdout || "") });
}
}
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);
return JSON.stringify({
timestamp: new Date(snapshot.timestamp).toISOString(),
trigger: snapshot.trigger,
duration_ms: snapshot.duration_ms,
offline_count: offlineDevices.length,
online_count: onlineDevices.length,
total_commands: totalCmds,
failed_commands: failCmds,
offline_devices: offlineDevices,
online_devices: onlineDevices,
}, null, 2);
}
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) {
console.error("[RS_DEBUG] ═══ registerInspectionHandlers 被调用了 ═══");
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);
});
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),
};
}
});
// ---------- 巡检报告快照 ----------
// 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();
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.aiBridgeContext?.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) {
const snapshot = reportStore.getSnapshot(readFn, snapshotId);
if (!snapshot) return;
reportStore.setSnapshotAiStatus(readFn, writeFn, snapshotId, "running");
_broadcastReportAiStatus(snapshotId, "running");
try {
const bridge = require("../aiBridge.cjs");
const activeProviders = bridge.aiBridgeContext?.providerConfigs || [];
const provider = activeProviders.find(p => p.id === providerId)
|| activeProviders.find(p => p.enabled !== false);
if (!provider) throw new Error("AI provider 未配置");
const resolved = bridge.aiBridgeContext.resolveProviderApiKey(provider.id);
if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
const userContent = _buildSnapshotPrompt(snapshot);
const url = _buildAiUrl(provider);
const headers = { "Content-Type": "application/json", "Authorization": "Bearer __IPC_SECURED__" };
const body = JSON.stringify(_buildAiBody(provider, userContent));
const patched = bridge.aiBridgeContext.injectApiKeyIntoRequest(url, headers, provider.id);
const skipTLS = bridge.aiBridgeContext.shouldSkipTLSVerify(provider.id);
const result = await _doHttpFetch(patched.url, patched.headers, body, { skipTLS, timeoutMs: 60000 });
if (!result.ok) throw new Error(result.error || `HTTP ${result.status}`);
const raw = _parseAiResponse(provider, result.data);
const parsed = _extractJson(raw);
const summary = parsed || { health_summary: raw, offline_devices: [], hidden_risks: [], recommendations: [] };
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, summary);
_broadcastReportAiStatus(snapshotId, "done");
} catch (err) {
const msg = err?.message || String(err);
console.error("[runReportAiSummary] 失败:", msg);
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, null, msg);
_broadcastReportAiStatus(snapshotId, "failed", msg);
}
}
ipcMain.handle("inspection:runReportSnapshot", async (_e, payload) => {
const { deviceTargets = [], trigger = "manual" } = payload || {};
_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;
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 };
});
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" };
// 后台独立执行 —— 不阻塞 IPC 返回
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;
// ---------- 调度器控制 ----------
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 || "",
};
}
}
if (!_scheduler) {
_scheduler = new InspectionScheduler({
readFn,
writeFn,
execFn: async (hostId, command) => {
// 优先用前端传入的 hostConfigs
if (_hostConfigs && _hostConfigs[hostId]) {
const config = _hostConfigs[hostId];
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 配置`);
},
pollIntervalMs: 30 * 1000,
concurrency: 5,
timeoutMs: 30 * 1000,
hostInfo,
onAlertChanged: broadcastAlertChanged,
});
}
_scheduler.start();
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: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") {
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} 台`);
try {
await _executeSnapshot(targets, trigger);
} catch (e) {
console.error("[reportScheduler] 定时快照执行失败:", e?.message || String(e));
}
}
console.error("[RS_DEBUG] ═══ 准备启动 reportScheduler ═══");
try {
const cfg = reportScheduleStore.getSchedule(readFn);
console.error("[RS_DEBUG] cfg.enabled=", cfg?.enabled, "enabledDeviceIds=", JSON.stringify(cfg?.enabledDeviceIds));
console.error("[RS_DEBUG] isRunning()=", reportScheduler.isRunning());
if (!reportScheduler.isRunning()) {
reportScheduler.start({
readStorage: readFn,
writeStorage: writeFn,
runSnapshotFn: (deviceIds, trigger) => _runScheduledSnapshot(deviceIds, trigger),
getAllDeviceIdsFn: () => Object.keys(_hostConfigs || {}),
});
console.error("[RS_DEBUG] ✅ start() 已调用完成");
} else {
console.error("[RS_DEBUG] ⚠️ 已在运行中");
}
} catch (e) {
console.error("[RS_DEBUG] ❌ 启动失败:", e?.message || String(e), e?.stack);
}
return {
getScheduler: () => _scheduler,
createScheduler: (opts) => {
_scheduler = new InspectionScheduler({
readFn,
writeFn,
execFn: execCommand,
onAlertChanged: broadcastAlertChanged,
...opts,
});
return _scheduler;
},
};
}
module.exports = {
registerInspectionHandlers,
getLabels,
};