"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), }); } 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), }); } 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 已注册"); return { getScheduler: () => _scheduler, createScheduler: (opts) => { _scheduler = new InspectionScheduler({ readFn, writeFn, execFn: execCommand, onAlertChanged: broadcastAlertChanged, ...opts, }); return _scheduler; }, }; // ─── Electron 启动时自动启动 reportScheduler ─── // 负责:per-device 定时快照 tick(主进程自身 SSH 执行) + dailyReport HH:MM 定时聚合 // 主进程从 _hostConfigs(InspectionScheduler 已初始化的已解密 SSH 凭据缓存)取配置 // 执行快照,不依赖 renderer 是否打开巡检报告页面 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), }); console.error("[RS_DEBUG] ✅ start() 已调用完成"); } else { console.error("[RS_DEBUG] ⚠️ 已在运行中"); } } catch (e) { console.error("[RS_DEBUG] ❌ 启动失败:", e?.message || String(e), e?.stack); } } module.exports = { registerInspectionHandlers, getLabels, };