Files
SenMeshNet/electron/bridges/inspection/index.cjs

2166 lines
88 KiB
JavaScript
Raw Normal View History

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