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