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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user