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