feat: 巡检增强 / 拓扑大屏 / 远程 HTTP / portable 绿色打包
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled

巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立
Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心
与告警中心页增强。

拓扑大屏:MonitoringTopologyView 扩展分组/布局控制,
useMonitoringData 重构数据流。

远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露
IPC,registerBridges 注册 netcatty:http:open 桥。

portable 绿色打包:
- electron-builder.config.cjs 恢复原始白名单(61MB app.asar),
  收集器自动追踪运行时依赖;
- portableData.cjs 重写为 .portable 标记检测;
- scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage
  的 http://localhost:5173 origin 批量迁移为 app://netcatty;
- scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
This commit is contained in:
2026-10-19 21:11:57 +08:00
committed by trae-solo-agent
parent 336c3afd47
commit 2e71e03d71
34 changed files with 3012 additions and 230 deletions

View File

@@ -1,4 +1,4 @@
"use strict";
"use strict";
/**
* 告警记录存储
@@ -45,8 +45,10 @@ function _ensureLoaded(readFn) {
}
function _safeParse(text, def) {
if (text === null || text === undefined) return def;
if (typeof text === "object") return text;
try { return JSON.parse(text); }
catch (e) { return def; }
catch { return def; }
}
function _saveAlerts(writeFn) {

View File

@@ -1,4 +1,4 @@
"use strict";
"use strict";
/**
* 告警规则数据存储
@@ -33,8 +33,10 @@ function _ensureLoaded(readFn) {
}
function _safeParse(text, def) {
if (text === null || text === undefined) return def;
if (typeof text === "object") return text;
try { return JSON.parse(text); }
catch (e) { return def; }
catch { return def; }
}
function _save(writeFn) {

View File

@@ -1,4 +1,4 @@
"use strict";
"use strict";
/**
* 日运行报告存储 + 聚合
@@ -30,6 +30,8 @@ const { STORAGE_KEY_DAILY_REPORTS } = require("./storageKeys.cjs");
const MAX_DAILY_REPORTS = 120; // 保留最近约 4 个月(每天 1 条)
function _safeParse(raw, def) {
if (raw === null || raw === undefined) return def;
if (typeof raw === "object") return raw;
try { return JSON.parse(raw); }
catch { return def; }
}
@@ -276,6 +278,8 @@ function clearAll(readFn, writeFn) {
_save(writeFn);
}
function _resetForTest() { _reports = null; }
module.exports = {
MAX_DAILY_REPORTS,
buildDailyReport,
@@ -286,4 +290,5 @@ module.exports = {
_dayStart,
_dayEnd,
_dateStr,
_resetForTest,
};

View File

@@ -58,6 +58,9 @@ function _ensureLoaded(readFn) {
function _safeParse(text, def) {
if (text === null || text === undefined) return def;
// 已经是对象/数组 → 直接返回
if (typeof text === "object") return text;
if (typeof text !== "string") return def;
let parsed;
try { parsed = JSON.parse(text); }
catch (e) { return def; }

View File

@@ -36,16 +36,26 @@ let _scheduler = null;
let _isSnapshotRunning = false; // 巡检执行状态(主进程持有,renderer 可查询)
// 读报告产品名(PDF 巡检报告标题专用)
// 优先级:netcatty_report_product_name_v1 > netcatty_product_name_v1 > 默认值
// 优先级:_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 ? JSON.parse(raw) : null;
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 ? JSON.parse(raw) : null;
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 智能巡检系统";
@@ -185,25 +195,28 @@ function _buildSnapshotPrompt(snapshot) {
if (isOffline) {
offlineDevices.push({
device: d.hostname,
reason: (cmds[0]?.stderr || cmds[0]?.stdout || "SSH连接失败").slice(0, 200),
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, purpose: c.purpose, status: "fail",
output: (c.stdout || c.stderr || "").slice(0, 600),
cmd: c.cmd, status: "fail",
output: (c.stdout || c.stderr || "").slice(0, 200),
});
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 || "") });
// 只保留 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({
@@ -218,17 +231,20 @@ function _buildSnapshotPrompt(snapshot) {
}
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({
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);
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) {
@@ -411,6 +427,25 @@ function registerInspectionHandlers(ipcMain, options) {
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);
});
@@ -811,6 +846,318 @@ function registerInspectionHandlers(ipcMain, options) {
}
});
// ---------- 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: {
@@ -872,6 +1219,7 @@ function registerInspectionHandlers(ipcMain, options) {
// 内部函数:执行一次快照(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] ════════════════════════════════════`);
@@ -1033,7 +1381,7 @@ function registerInspectionHandlers(ipcMain, options) {
// 自动触发 AI 分析 —— 不阻塞快照返回,后台独立执行
const bridge = require("../aiBridge.cjs");
const activeProviders = bridge.aiBridgeContext?.providerConfigs || [];
const activeProviders = bridge.providerConfigs || [];
const defaultProvider = activeProviders.find(p => p?.selected) || activeProviders[0];
const defaultProviderId = defaultProvider?.id;
setImmediate(() => {
@@ -1047,40 +1395,66 @@ function registerInspectionHandlers(ipcMain, options) {
/** AI 内部执行函数 —— 自动触发和 IPC handler 共用同一套逻辑 */
async function _runReportAiSummaryInternal(snapshotId, providerId) {
console.log("[ReportAiSummaryInternal] 🚀 开始执行 snapshotId=", snapshotId, "providerId=", providerId);
const snapshot = reportStore.getSnapshot(readFn, snapshotId);
if (!snapshot) return;
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.aiBridgeContext?.providerConfigs || [];
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 未配置");
const resolved = bridge.aiBridgeContext.resolveProviderApiKey(provider.id);
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, userContent));
const patched = bridge.aiBridgeContext.injectApiKeyIntoRequest(url, headers, provider.id);
const skipTLS = bridge.aiBridgeContext.shouldSkipTLSVerify(provider.id);
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);
}
@@ -1141,6 +1515,189 @@ function registerInspectionHandlers(ipcMain, options) {
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,
type: h.type || "",
distro: h.distro || h.catalogId || "",
group: h.group || "",
}));
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);
@@ -1150,8 +1707,12 @@ function registerInspectionHandlers(ipcMain, options) {
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)));
});
@@ -1360,6 +1921,7 @@ function registerInspectionHandlers(ipcMain, options) {
// 存储前端传入的设备 SSH 配置(hostId → sshConfig)
let _hostConfigs = null;
let _loggedHostCreds = false;
// ---------- 调度器控制 ----------
ipcMain.handle("inspection:scheduler:start", (_e, options = {}) => {
@@ -1379,21 +1941,35 @@ function registerInspectionHandlers(ipcMain, options) {
}
}
// 持久化 hostInfo 到 inspection-data.json,供远程 HTTP Server 读取补全设备名
try {
writeFn("_host_info_cache", hostInfo);
} catch {}
if (!_scheduler) {
_scheduler = new InspectionScheduler({
readFn,
writeFn,
execFn: async (hostId, command) => {
// 优先用前端传入的 hostConfigs
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");
@@ -1403,14 +1979,56 @@ function registerInspectionHandlers(ipcMain, options) {
}
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 时直接返回;这里调 scanAllNow 让新 hostConfigs 立即生效,
// 不用等下一个 30 秒 tick
const wasRunning = _scheduler.isRunning();
_scheduler.start();
if (wasRunning && Object.keys(_hostConfigs || {}).length > 0) {
// 已在跑 → 立即触发一次 scanAllNow,把新 hostConfigs 对应的设备加入队列
setImmediate(() => {
try {
_scheduler.scanAllNow();
} catch (e) {
console.log("[Inspection] 触发立即巡检失败:", e.message);
}
});
}
return _scheduler.getStatus();
});
@@ -1426,6 +2044,23 @@ function registerInspectionHandlers(ipcMain, options) {
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);

View File

@@ -1,4 +1,4 @@
"use strict";
"use strict";
/**
* 巡检结果存储
@@ -22,8 +22,10 @@ function _ensureLoaded(readFn) {
}
function _safeParse(text, def) {
if (text === null || text === undefined) return def;
if (typeof text === "object") return text;
try { return JSON.parse(text); }
catch (e) { return def; }
catch { return def; }
}
function _save(writeFn) {

View File

@@ -33,7 +33,8 @@ class InspectionScheduler {
* @param {function} options.readFn - 读取存储 (key) => string
* @param {function} options.writeFn - 写入存储 (key, value) => void
* @param {function} options.getConnectionFn - 获取 SSH 连接 (hostId) => connection 或 null
* @param {function} options.execFn - 执行命令 (hostId, command) => Promise<string>
* @param {function} options.execFn - 执行单条命令 (hostId, command) => Promise<string>
* @param {function} [options.execCommandsFn] - 批量执行多条命令 (hostId, commands) => Promise<{ results, stdout }>
* @param {function} [options.onResult] - 单设备巡检完成回调
* @param {number} [options.pollIntervalMs] - 轮询间隔
* @param {number} [options.concurrency] - 并发数
@@ -44,6 +45,7 @@ class InspectionScheduler {
this.writeFn = options.writeFn;
this.getConnectionFn = options.getConnectionFn;
this.execFn = options.execFn;
this.execCommandsFn = options.execCommandsFn;
this.onResult = options.onResult || (() => {});
this.onAlertChanged = options.onAlertChanged || (() => {});
this.pollIntervalMs = options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
@@ -93,6 +95,13 @@ class InspectionScheduler {
if (!this._running) return;
try {
// 全局自动巡检开关(一键启用/停止)
const autoEnabledRaw = this.readFn("inspection-auto-enabled");
const autoEnabled = autoEnabledRaw !== "false"; // 默认 true
if (!autoEnabled) {
return; // 全局停止 —— 跳过本次 tick
}
// 1. 获取所有启用巡检的设备
const devices = deviceStore.getEnabledDevices(this.readFn);
@@ -149,6 +158,8 @@ class InspectionScheduler {
}
// 2. 创建采集器
// connectionInfo 只传 { host: hostId } 作为标识——adapter 的 execCommands 会被 options.execCommands 覆盖,
// 真正的 SSH 凭据由主进程 execCommandsFn 从 _hostConfigs 读取(和 execFn 同理)
const adapter = adapterRegistry.createAdapter(
catalog.adapter_class,
{ host: hostId },
@@ -157,6 +168,11 @@ class InspectionScheduler {
if (!this.execFn) throw new Error("execFn 未配置");
return this.execFn(hostId, cmd);
},
execCommands: this.execCommandsFn
? async (commands, options) => {
return this.execCommandsFn(hostId, commands, options);
}
: undefined,
timeoutMs: this.timeoutMs,
}
);
@@ -281,10 +297,8 @@ class InspectionScheduler {
console.log(`[InspectionScheduler] 告警评估 device=${deviceName}(${deviceExt.host_id}) online=${metrics.online} 规则数=${rules.length}`);
for (const rule of rules) {
// 特殊处理:online 指标
if (rule.metric === "online") {
const isOnline = metrics.online !== false;
// threshold 为 "false" 表示离线时告警
const shouldAlert = rule.threshold === "false" ? !isOnline : isOnline;
console.log(`[InspectionScheduler] 离线规则: name=${rule.name} enabled=${rule.enabled} threshold=${rule.threshold} isOnline=${isOnline} shouldAlert=${shouldAlert}`);
const alertResult = alertRecordStore.processAlert(
@@ -308,8 +322,8 @@ class InspectionScheduler {
const isTriggered = alertRuleStore.evaluateRule(value, rule.operator, rule.threshold);
const hostInfo = this._hostInfo[deviceExt.host_id] || {};
const deviceName = hostInfo.label || hostInfo.hostname || deviceExt.host_id;
// 注意:hostInfo / deviceName 在函数顶部 L285-286 已声明,此处直接复用,
// 避免同名 const 重复声明导致 TDZ(Cannot access 'deviceName' before initialization)
const alertResult = alertRecordStore.processAlert(
this.readFn,
this.writeFn,

View File

@@ -81,9 +81,17 @@ function flushSync() {
_flushToDisk();
}
function resetCache() {
// 先确保内存中的修改已写到磁盘(200ms 异步保存可能还没触发)
_flushToDisk();
_storageCache = null;
_loadFromDisk();
}
module.exports = {
init,
readStorage,
writeStorage,
flushSync,
resetCache,
};

View File

@@ -1,4 +1,4 @@
"use strict";
"use strict";
/**
* 巡检报告调度配置存储
@@ -26,6 +26,8 @@ const DEFAULTS = {
};
function _safeParse(raw, def) {
if (raw === null || raw === undefined) return def;
if (typeof raw === "object") return raw;
try { return JSON.parse(raw); }
catch { return def; }
}

View File

@@ -90,7 +90,8 @@ function start({ readStorage, writeStorage, runSnapshotFn, onDailyReportReady, g
};
// 启动后等 60s tick 自动处理第一次(不立即触发,避免重启爆发)
console.log("[reportScheduler] 已启动,间隔", (cfg.defaultIntervalHours || 1), "h,下次 tick 检查在 60s 后");
const curCfg = reportScheduleStore.getSchedule(readFn);
console.log("[reportScheduler] 已启动,间隔", (curCfg.defaultIntervalHours || 1), "h,下次 tick 检查在 60s 后");
// 每 60 秒 tick 一次
_tickTimer = setInterval(() => { tick().catch(e => console.error("[reportScheduler] tick 异常:", e)); }, 60 * 1000);

View File

@@ -1,4 +1,4 @@
"use strict";
"use strict";
/**
* 巡检报告快照存储
@@ -35,8 +35,10 @@ const MAX_CMD_STDOUT = 4000; // 单条命令 stdout 保留前 4000 字符
let _snapshots = null;
function _safeParse(text, def) {
if (text === null || text === undefined) return def;
if (typeof text === "object") return text;
try { return JSON.parse(text); }
catch (e) { return def; }
catch { return def; }
}
function _ensureLoaded(readFn) {
@@ -215,6 +217,8 @@ function clearSnapshotAiSummary(readFn, writeFn, snapshotId) {
return true;
}
function _resetForTest() { _snapshots = null; }
module.exports = {
MAX_SNAPSHOTS,
MAX_CMD_STDOUT,
@@ -227,4 +231,5 @@ module.exports = {
setSnapshotAiStatus,
updateSnapshotAiSummary,
clearSnapshotAiSummary,
_resetForTest,
};