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
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:
@@ -241,6 +241,19 @@ const activeStreams = new Map();
|
||||
// ── Provider registry (synced from renderer, keys stay encrypted) ──
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
let providerConfigs = [];
|
||||
const _PROVIDER_STORAGE_KEY = "_ai_provider_configs";
|
||||
|
||||
function loadProviderConfigsFromStorage() {
|
||||
try {
|
||||
const { readStorage } = require("./inspection/inspectionStorage.cjs");
|
||||
const raw = readStorage(_PROVIDER_STORAGE_KEY);
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
providerConfigs = raw;
|
||||
console.log("[aiBridge] 从 inspectionStorage 加载 providerConfigs:", raw.length, "条");
|
||||
}
|
||||
} catch {}
|
||||
return providerConfigs;
|
||||
}
|
||||
// Web search config (synced from renderer — apiKey stays encrypted, decrypted on use)
|
||||
let webSearchApiHost = null;
|
||||
let webSearchApiKeyEncrypted = null;
|
||||
@@ -930,7 +943,10 @@ function createHandlerContext(ipcMain) {
|
||||
get cliDiscoveryFilePath() { return cliDiscoveryFilePath; },
|
||||
set cliDiscoveryFilePath(value) { cliDiscoveryFilePath = value; },
|
||||
activeStreams,
|
||||
get providerConfigs() { return providerConfigs; },
|
||||
get providerConfigs() {
|
||||
if (!providerConfigs || providerConfigs.length === 0) loadProviderConfigsFromStorage();
|
||||
return providerConfigs;
|
||||
},
|
||||
set providerConfigs(value) { providerConfigs = value; },
|
||||
get webSearchApiHost() { return webSearchApiHost; },
|
||||
set webSearchApiHost(value) { webSearchApiHost = value; },
|
||||
@@ -987,6 +1003,21 @@ function registerHandlers(ipcMain) {
|
||||
if (externalMcpController) {
|
||||
externalMcpController.registerHandlers(ipcMain, validateSenderOrSettings);
|
||||
}
|
||||
|
||||
// 启动时从 inspectionStorage 加载持久化的 provider configs
|
||||
// 解决重启后或远程浏览器场景下 providerConfigs 为空的问题
|
||||
try {
|
||||
const inspectionStorage = require("./inspection/inspectionStorage.cjs");
|
||||
const raw = inspectionStorage.readStorage("_ai_provider_configs");
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
providerConfigs = raw;
|
||||
console.log("[aiBridge] 启动时从 inspectionStorage 加载 providerConfigs:", raw.length, "条");
|
||||
} else {
|
||||
console.log("[aiBridge] inspectionStorage 中无持久化 providerConfigs(renderer 还未 sync 过)");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[aiBridge] 启动时加载 providerConfigs 失败:", e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Abort active streams and child processes on shutdown
|
||||
@@ -1046,4 +1077,9 @@ module.exports = {
|
||||
DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS,
|
||||
getExternalMcpController: () => externalMcpController,
|
||||
get aiBridgeContext() { return registeredContext; },
|
||||
get providerConfigs() { return providerConfigs; },
|
||||
resolveProviderApiKey,
|
||||
injectApiKeyIntoRequest,
|
||||
shouldSkipTLSVerify,
|
||||
decryptApiKeyValue,
|
||||
};
|
||||
|
||||
@@ -60,6 +60,11 @@ function registerProviderHandlers(ctx) {
|
||||
if (Array.isArray(providers)) {
|
||||
providerConfigs = providers;
|
||||
rebuildProviderFetchHosts();
|
||||
// 持久化到 inspectionStorage,让 remoteHttpServer / 主进程内部 RCA 能读到
|
||||
try {
|
||||
const { writeStorage } = require("../inspection/inspectionStorage.cjs");
|
||||
writeStorage("_ai_provider_configs", providers);
|
||||
} catch {}
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -81,9 +81,17 @@ function flushSync() {
|
||||
_flushToDisk();
|
||||
}
|
||||
|
||||
function resetCache() {
|
||||
// 先确保内存中的修改已写到磁盘(200ms 异步保存可能还没触发)
|
||||
_flushToDisk();
|
||||
_storageCache = null;
|
||||
_loadFromDisk();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
readStorage,
|
||||
writeStorage,
|
||||
flushSync,
|
||||
resetCache,
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -268,10 +268,10 @@ const portableData = applyPortableDataDirectory({ app });
|
||||
if (portableData) {
|
||||
console.info(`[Main] Portable data directory: ${portableData.dataDirectory}`);
|
||||
}
|
||||
const appPath = path.join(__dirname, "..");
|
||||
const preload = path.join(__dirname, "preload.cjs");
|
||||
const isMac = process.platform === "darwin";
|
||||
const appIconManager = require("./bridges/appIconManager.cjs");
|
||||
const appPath = path.join(__dirname, "..");
|
||||
appIconManager.initializeAppIconManager(appPath, {
|
||||
preferPublic: !app.isPackaged,
|
||||
isMac,
|
||||
@@ -1313,6 +1313,7 @@ if (!gotLock) {
|
||||
// Application lifecycle
|
||||
app.whenReady().then(async () => {
|
||||
registerAppProtocol();
|
||||
|
||||
const initialSshDeepLinkPreference = applyInitialSshDeepLinkPreference({
|
||||
enabled: sshDeepLinkEnabled,
|
||||
applyPreference: (enabled) => applySshProtocolClientPreference({ app, enabled, isDev }),
|
||||
|
||||
@@ -1196,6 +1196,21 @@ function createBridgeRegistrar(context) {
|
||||
});
|
||||
|
||||
console.log('[Main] All bridges registered successfully');
|
||||
|
||||
// ─── 远程 HTTP Server(单机版浏览器访问) ───
|
||||
try {
|
||||
const remoteHttp = require("../remoteHttpServer.cjs");
|
||||
// 复用已初始化的 inspectionStorage,共享同一个模块级缓存
|
||||
// 这样 Electron 本地改配置后,远程 HTTP API 立即读到最新值
|
||||
ipcMain.handle("remoteHttp:getStatus", () => remoteHttp.getStatus());
|
||||
ipcMain.handle("remoteHttp:configure", (_e, enabled, port) => remoteHttp.configure(enabled, port));
|
||||
// 自动启动(打包版才有意义,开发版也启方便调试)
|
||||
remoteHttp.start({ storage: inspectionStorage }).catch((e) => {
|
||||
console.warn("[RemoteHTTP] auto-start failed:", e.message);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("[RemoteHTTP] 远程 HTTP Server 初始化失败:", e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return registerBridges;
|
||||
|
||||
@@ -6,25 +6,30 @@ function applyPortableDataDirectory({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
}) {
|
||||
if (platform !== "win32" || !app?.isPackaged) return null;
|
||||
// 触发条件:exe 所在目录下有 .portable 标记文件
|
||||
// 这样开发版(没有 .portable)完全不受影响,只有绿色便携版才生效
|
||||
if (platform !== "win32") return null;
|
||||
|
||||
const portableLauncherDirectory = typeof env?.PORTABLE_EXECUTABLE_DIR === "string"
|
||||
? env.PORTABLE_EXECUTABLE_DIR.trim()
|
||||
: "";
|
||||
const baseDirectory = portableLauncherDirectory || path.dirname(app.getPath("exe"));
|
||||
const dataDirectory = path.join(baseDirectory, "data");
|
||||
if (!fs.existsSync(dataDirectory) || !fs.statSync(dataDirectory).isDirectory()) {
|
||||
let exeDir;
|
||||
try { exeDir = path.dirname(app.getPath("exe")); } catch { return null; }
|
||||
|
||||
const markerFile = path.join(exeDir, ".portable");
|
||||
if (!fs.existsSync(markerFile)) return null;
|
||||
|
||||
const dataDirectory = path.join(exeDir, "data");
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(dataDirectory)) {
|
||||
fs.mkdirSync(dataDirectory, { recursive: true });
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.info(`[Main] Portable data directory (via .portable marker): ${dataDirectory}`);
|
||||
app.setPath("userData", dataDirectory);
|
||||
app.setPath("sessionData", dataDirectory);
|
||||
return {
|
||||
dataDirectory,
|
||||
source: portableLauncherDirectory
|
||||
? "portable-launcher-directory"
|
||||
: "executable-directory",
|
||||
};
|
||||
return { dataDirectory, source: "marker-file" };
|
||||
}
|
||||
|
||||
module.exports = { applyPortableDataDirectory };
|
||||
|
||||
@@ -2081,6 +2081,7 @@ function createPreloadApi(ctx) {
|
||||
getAllExtensions: () => ipcRenderer.invoke("inspection:getAllExtensions"),
|
||||
getExtension: (hostId) => ipcRenderer.invoke("inspection:getExtension", hostId),
|
||||
upsertExtension: (hostId, data) => ipcRenderer.invoke("inspection:upsertExtension", hostId, data),
|
||||
batchSetInspectionEnabled: (enabled) => ipcRenderer.invoke("inspection:batchSetInspectionEnabled", enabled),
|
||||
deleteExtension: (hostId) => ipcRenderer.invoke("inspection:deleteExtension", hostId),
|
||||
getEffectiveInterval: (hostId) => ipcRenderer.invoke("inspection:getEffectiveInterval", hostId),
|
||||
getEnabledDevices: () => ipcRenderer.invoke("inspection:getEnabledDevices"),
|
||||
@@ -2105,6 +2106,8 @@ function createPreloadApi(ctx) {
|
||||
schedulerStatus: () => ipcRenderer.invoke("inspection:scheduler:status"),
|
||||
scanNow: (hostId) => ipcRenderer.invoke("inspection:scheduler:scanNow", hostId),
|
||||
scanAllNow: () => ipcRenderer.invoke("inspection:scheduler:scanAllNow"),
|
||||
getAutoEnabled: () => ipcRenderer.invoke("inspection:scheduler:getAutoEnabled"),
|
||||
setAutoEnabled: (enabled) => ipcRenderer.invoke("inspection:scheduler:setAutoEnabled", enabled),
|
||||
runInspection: (payload) => ipcRenderer.invoke("inspection:runInspection", payload),
|
||||
runSingleCommand: (payload) => ipcRenderer.invoke("inspection:runSingleCommand", payload),
|
||||
// 巡检报告快照
|
||||
@@ -2116,6 +2119,23 @@ function createPreloadApi(ctx) {
|
||||
saveReportAiSummary: (snapshotId, aiSummary) => ipcRenderer.invoke("inspection:saveReportAiSummary", { snapshotId, aiSummary }),
|
||||
clearReportAiSummary: (snapshotId) => ipcRenderer.invoke("inspection:clearReportAiSummary", { snapshotId }),
|
||||
runReportAiSummary: (snapshotId, providerId) => ipcRenderer.invoke("inspection:runReportAiSummary", { snapshotId, providerId }),
|
||||
// 拓扑位置持久化(供远程浏览器同步读取)
|
||||
saveTopologyPositions: (positions) => ipcRenderer.invoke("inspection:saveTopologyPositions", positions),
|
||||
getTopologyPositions: () => ipcRenderer.invoke("inspection:getTopologyPositions"),
|
||||
// 监控大屏标题同步
|
||||
saveDashboardTitle: (title) => ipcRenderer.invoke("inspection:saveDashboardTitle", title),
|
||||
getDashboardTitle: () => ipcRenderer.invoke("inspection:getDashboardTitle"),
|
||||
saveProductName: (name) => ipcRenderer.invoke("inspection:saveProductName", name),
|
||||
getProductName: () => ipcRenderer.invoke("inspection:getProductName"),
|
||||
saveReportProductName: (name) => ipcRenderer.invoke("inspection:saveReportProductName", name),
|
||||
getReportProductName: () => ipcRenderer.invoke("inspection:getReportProductName"),
|
||||
saveVaultHosts: (hosts) => ipcRenderer.invoke("inspection:saveVaultHosts", hosts),
|
||||
getVaultHosts: () => ipcRenderer.invoke("inspection:getVaultHosts"),
|
||||
// Vault 通用数据同步(keys/proxies/port_forwards/snippets/notes/known_hosts/connection_logs/groups/identities)
|
||||
syncVaultData: (key, data) => ipcRenderer.invoke("inspection:syncVault:" + key, data),
|
||||
getVaultData: (key) => ipcRenderer.invoke("inspection:getVault:" + key),
|
||||
saveTopologyLinks: (links) => ipcRenderer.invoke("inspection:saveTopologyLinks", links),
|
||||
getTopologyLinks: () => ipcRenderer.invoke("inspection:getTopologyLinks"),
|
||||
// 巡检报告调度配置
|
||||
getReportSchedule: () => ipcRenderer.invoke("inspection:getReportSchedule"),
|
||||
saveReportSchedule: (config) => ipcRenderer.invoke("inspection:saveReportSchedule", config),
|
||||
@@ -2143,6 +2163,12 @@ function createPreloadApi(ctx) {
|
||||
return () => ipcRenderer.removeListener("inspection:ruleChanged", handler);
|
||||
},
|
||||
},
|
||||
|
||||
// ─── 远程 HTTP Server(浏览器访问单机版) ───
|
||||
remoteHttp: {
|
||||
getStatus: () => ipcRenderer.invoke("remoteHttp:getStatus"),
|
||||
configure: (enabled, port) => ipcRenderer.invoke("remoteHttp:configure", enabled, port),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
1082
electron/remoteHttpServer.cjs
Normal file
1082
electron/remoteHttpServer.cjs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user