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