2026-10-19 21:11:57 +08:00
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* NetMesh 远程 HTTP Server —— 单机版浏览器可通过 URL 访问完整运行监控页面
|
|
|
|
|
|
*
|
|
|
|
|
|
* 零外部依赖,用 Node 内置 http 模块。
|
|
|
|
|
|
* 启动条件:storage 里 "netcatty_remote_access" 的 enabled === true。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 安全模型:
|
|
|
|
|
|
* - 启动时在存储里生成随机 token(24 位 hex)
|
|
|
|
|
|
* - 访问入口(/、/index.html)和 /api/* 都要求 URL query 带 ?token=xxx
|
|
|
|
|
|
* - token 通过注入 script 传到 window.__REMOTE_TOKEN__,让 SPA 内 fetch 自动带上
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const http = require("http");
|
|
|
|
|
|
const fs = require("fs");
|
|
|
|
|
|
const path = require("path");
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const os = require("os");
|
2026-10-19 21:11:57 +08:00
|
|
|
|
const crypto = require("crypto");
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// ─── 获取本机局域网 IPv4(优先以太网,过滤虚拟网卡) ────
|
|
|
|
|
|
function getLanIp() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const nets = os.networkInterfaces();
|
|
|
|
|
|
const candidates = [];
|
|
|
|
|
|
// 虚拟/内部/隧道网卡关键词(命中即跳过)
|
|
|
|
|
|
const VIRTUAL = [
|
|
|
|
|
|
"loopback", "docker", "veth", "vmware", "virtualbox", "hyper-v", "vEthernet",
|
|
|
|
|
|
"vbox", "bridge", "tailscale", "zerotier", "wireguard", "tun", "tap",
|
|
|
|
|
|
"bluetooth", "bth", "wsl", "localhost", "回环", "虚拟", "蓝牙",
|
|
|
|
|
|
];
|
|
|
|
|
|
// WiFi 关键词(降权)
|
|
|
|
|
|
const WIFI = ["wlan", "wi-fi", "wifi", "wireless", "无线"];
|
|
|
|
|
|
// 以太网关键词(优先)
|
|
|
|
|
|
const ETHERNET = ["以太网", "ethernet", "eth", "enp", "ens", "eno", "bond", "team"];
|
|
|
|
|
|
|
|
|
|
|
|
for (const ifaceName of Object.keys(nets)) {
|
|
|
|
|
|
const lower = ifaceName.toLowerCase();
|
|
|
|
|
|
// 过滤虚拟网卡
|
|
|
|
|
|
if (VIRTUAL.some((k) => lower.includes(k.toLowerCase()))) continue;
|
|
|
|
|
|
|
|
|
|
|
|
for (const net of nets[ifaceName] || []) {
|
|
|
|
|
|
if (net.family !== "IPv4" || net.internal || !net.address) continue;
|
|
|
|
|
|
const addr = net.address;
|
|
|
|
|
|
// 只保留内网段 IP
|
|
|
|
|
|
const isPrivate =
|
|
|
|
|
|
/^192\.168\./.test(addr) ||
|
|
|
|
|
|
/^10\./.test(addr) ||
|
|
|
|
|
|
/^172\.(1[6-9]|2\d|3[01])\./.test(addr);
|
|
|
|
|
|
if (!isPrivate) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// 基础分:以太网 +100,WiFi +50,其他 +10
|
|
|
|
|
|
let score = 10;
|
|
|
|
|
|
if (ETHERNET.some((k) => lower.includes(k.toLowerCase()))) score = 100;
|
|
|
|
|
|
else if (WIFI.some((k) => lower.includes(k.toLowerCase()))) score = 50;
|
|
|
|
|
|
|
|
|
|
|
|
candidates.push({ addr, score, iface: ifaceName });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (candidates.length === 0) return "127.0.0.1";
|
|
|
|
|
|
candidates.sort((a, b) => b.score - a.score);
|
|
|
|
|
|
console.log("[RemoteHTTP] getLanIp 候选:", candidates.map((c) => `${c.addr}(${c.iface},score=${c.score})`).join(", "));
|
|
|
|
|
|
return candidates[0].addr;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return "127.0.0.1";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
let _server = null;
|
|
|
|
|
|
let _port = 8848;
|
|
|
|
|
|
let _token = null;
|
|
|
|
|
|
let _storage = null; // { readStorage, writeStorage }
|
2026-10-30 12:12:04 +08:00
|
|
|
|
let _onSettingsChanged = null; // 远程写完设置后通知主窗口
|
2026-10-19 21:11:57 +08:00
|
|
|
|
let _distDir = null; // 运行时解析的 dist 绝对路径
|
|
|
|
|
|
|
|
|
|
|
|
const STORAGE_KEY = "netcatty_remote_access";
|
|
|
|
|
|
|
|
|
|
|
|
// MIME 类型映射
|
|
|
|
|
|
const MIME = {
|
|
|
|
|
|
".html": "text/html; charset=utf-8",
|
|
|
|
|
|
".js": "application/javascript; charset=utf-8",
|
|
|
|
|
|
".mjs": "application/javascript; charset=utf-8",
|
|
|
|
|
|
".css": "text/css; charset=utf-8",
|
|
|
|
|
|
".json": "application/json; charset=utf-8",
|
|
|
|
|
|
".svg": "image/svg+xml",
|
|
|
|
|
|
".png": "image/png",
|
|
|
|
|
|
".jpg": "image/jpeg",
|
|
|
|
|
|
".jpeg": "image/jpeg",
|
|
|
|
|
|
".gif": "image/gif",
|
|
|
|
|
|
".ico": "image/x-icon",
|
|
|
|
|
|
".woff": "font/woff",
|
|
|
|
|
|
".woff2": "font/woff2",
|
|
|
|
|
|
".ttf": "font/ttf",
|
|
|
|
|
|
".eot": "application/vnd.ms-fontobject",
|
|
|
|
|
|
".mp3": "audio/mpeg",
|
|
|
|
|
|
".mp4": "video/mp4",
|
|
|
|
|
|
".wasm": "application/wasm",
|
|
|
|
|
|
".map": "application/json; charset=utf-8",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function sendJson(res, code, data) {
|
|
|
|
|
|
res.writeHead(code, {
|
|
|
|
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
|
|
|
|
"Cache-Control": "no-store",
|
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
|
});
|
|
|
|
|
|
res.end(JSON.stringify(data));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sendHtml(res, code, html) {
|
|
|
|
|
|
res.writeHead(code, {
|
|
|
|
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
|
|
|
|
"Cache-Control": "no-cache",
|
|
|
|
|
|
});
|
|
|
|
|
|
res.end(html);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readBody(req) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
let buf = "";
|
|
|
|
|
|
req.on("data", (c) => (buf += c));
|
|
|
|
|
|
req.on("end", () => resolve(buf));
|
|
|
|
|
|
req.on("error", reject);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseQuery(urlStr) {
|
|
|
|
|
|
const q = {};
|
|
|
|
|
|
const idx = urlStr.indexOf("?");
|
|
|
|
|
|
if (idx < 0) return { pathname: urlStr, query: q };
|
|
|
|
|
|
const pathname = urlStr.slice(0, idx);
|
|
|
|
|
|
urlStr
|
|
|
|
|
|
.slice(idx + 1)
|
|
|
|
|
|
.split("&")
|
|
|
|
|
|
.forEach((pair) => {
|
|
|
|
|
|
const [k, v] = pair.split("=");
|
|
|
|
|
|
if (k) q[decodeURIComponent(k)] = v ? decodeURIComponent(v) : "";
|
|
|
|
|
|
});
|
|
|
|
|
|
return { pathname, query: q };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── dist 路径解析 ────
|
|
|
|
|
|
function resolveDistDir() {
|
|
|
|
|
|
if (_distDir) return _distDir;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const dist = path.join(__dirname, "../dist");
|
|
|
|
|
|
if (fs.existsSync(dist)) {
|
|
|
|
|
|
_distDir = dist;
|
|
|
|
|
|
return _distDir;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 打包后可能在 app.asar 里
|
|
|
|
|
|
const dist2 = path.join(require("electron").app.getAppPath(), "dist");
|
|
|
|
|
|
if (fs.existsSync(dist2)) {
|
|
|
|
|
|
_distDir = dist2;
|
|
|
|
|
|
return _distDir;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { /* 没有 electron 模块,可能在独立测试 */ }
|
|
|
|
|
|
// 最后 fallback:项目根 dist
|
|
|
|
|
|
_distDir = path.resolve(__dirname, "..", "dist");
|
|
|
|
|
|
return _distDir;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── serve 静态文件 ────
|
|
|
|
|
|
function serveStatic(res, relPath) {
|
|
|
|
|
|
const distDir = resolveDistDir();
|
|
|
|
|
|
// 安全:禁止路径穿越
|
|
|
|
|
|
const safeRel = relPath.replace(/^\/+/, "").replace(/\.\.\//g, "");
|
|
|
|
|
|
const fullPath = path.join(distDir, safeRel);
|
|
|
|
|
|
// 确保文件在 dist 目录内
|
|
|
|
|
|
const real = path.resolve(fullPath);
|
|
|
|
|
|
if (!real.startsWith(path.resolve(distDir))) {
|
|
|
|
|
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
|
|
|
|
res.end("Forbidden");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
|
|
|
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
|
|
|
|
res.end("Not Found");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const ext = path.extname(fullPath).toLowerCase();
|
|
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
|
"Content-Type": MIME[ext] || "application/octet-stream",
|
|
|
|
|
|
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=3600",
|
|
|
|
|
|
// ES module script 要求 CORS 头(同域请求也需要)
|
|
|
|
|
|
"Access-Control-Allow-Origin": "*",
|
|
|
|
|
|
});
|
|
|
|
|
|
fs.createReadStream(fullPath).pipe(res);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 内部存储模块延迟加载 ────
|
|
|
|
|
|
function loadModules() {
|
|
|
|
|
|
const inspectionStorage = require("./bridges/inspection/inspectionStorage.cjs");
|
|
|
|
|
|
const deviceStore = require("./bridges/inspection/deviceStore.cjs");
|
|
|
|
|
|
const alertRecordStore = require("./bridges/inspection/alertRecordStore.cjs");
|
|
|
|
|
|
const inspectionResultStore = require("./bridges/inspection/inspectionResultStore.cjs");
|
|
|
|
|
|
const alertRuleStore = require("./bridges/inspection/alertRuleStore.cjs");
|
|
|
|
|
|
const reportStore = require("./bridges/inspection/reportStore.cjs");
|
|
|
|
|
|
const dailyReportStore = require("./bridges/inspection/dailyReportStore.cjs");
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const reportScheduleStore = require("./bridges/inspection/reportScheduleStore.cjs");
|
2026-10-30 17:00:38 +08:00
|
|
|
|
// v1.3 预案管理新增
|
|
|
|
|
|
const planTemplateStore = require("./bridges/inspection/planTemplateStore.cjs");
|
|
|
|
|
|
const devicePlanStore = require("./bridges/inspection/devicePlanStore.cjs");
|
|
|
|
|
|
const commandCatalogStore = require("./bridges/inspection/commandCatalogStore.cjs");
|
|
|
|
|
|
const aggregationEngine = require("./bridges/inspection/aggregationEngine.cjs");
|
|
|
|
|
|
return { inspectionStorage, deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore, reportStore, dailyReportStore, reportScheduleStore, planTemplateStore, devicePlanStore, commandCatalogStore, aggregationEngine };
|
2026-10-19 21:11:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 每次 API 请求前重置所有 store 的模块级缓存,强制从磁盘重新加载
|
|
|
|
|
|
function resetAllCaches(modules) {
|
|
|
|
|
|
const m = modules || loadModules();
|
|
|
|
|
|
try { m.inspectionStorage.resetCache?.(); } catch {}
|
|
|
|
|
|
try { m.deviceStore._resetForTest?.(); } catch {}
|
|
|
|
|
|
try { m.alertRecordStore._resetForTest?.(); } catch {}
|
|
|
|
|
|
try { m.inspectionResultStore._resetForTest?.(); } catch {}
|
|
|
|
|
|
try { m.alertRuleStore._resetForTest?.(); } catch {}
|
|
|
|
|
|
try { m.reportStore._resetForTest?.(); } catch {}
|
|
|
|
|
|
try { m.dailyReportStore._resetForTest?.(); } catch {}
|
2026-10-30 17:00:38 +08:00
|
|
|
|
// v1.3 预案
|
|
|
|
|
|
try { m.planTemplateStore._resetForTest?.(); } catch {}
|
|
|
|
|
|
try { m.devicePlanStore._resetForTest?.(); } catch {}
|
|
|
|
|
|
try { m.commandCatalogStore._resetForTest?.(); } catch {}
|
2026-10-19 21:11:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 构建 host_id → { label, hostname } 映射,多来源合并
|
|
|
|
|
|
* 1. 主来源:_host_info_cache(inspection scheduler start 时从 vault hostConfigs 缓存)
|
|
|
|
|
|
* 2. Fallback:告警记录里的 device_name(巡检执行时写入,label 优先)
|
|
|
|
|
|
* 3. Fallback:巡检结果里的 device_name / hostname
|
|
|
|
|
|
*
|
|
|
|
|
|
* 这个函数接收 stores 以便在 cache 未就绪时兜底。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildHostLabelMap(readStorage, stores = {}) {
|
|
|
|
|
|
const { alertRecordStore, inspectionResultStore } = stores;
|
|
|
|
|
|
const map = {};
|
|
|
|
|
|
|
|
|
|
|
|
// 0) _vault_hosts(前端 Electron 同步过来的完整 vault hosts,含 label/hostname/type/distro)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = readStorage("_vault_hosts");
|
|
|
|
|
|
if (raw && Array.isArray(raw)) {
|
|
|
|
|
|
for (const h of raw) {
|
|
|
|
|
|
if (h?.id) {
|
|
|
|
|
|
map[h.id] = {
|
|
|
|
|
|
label: h.label || "",
|
|
|
|
|
|
hostname: h.hostname || "",
|
|
|
|
|
|
type: h.type || "",
|
|
|
|
|
|
distro: h.distro || "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
// 1) _host_info_cache(scheduler start 时构建,可能 label 空)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = readStorage("_host_info_cache");
|
|
|
|
|
|
if (raw) {
|
|
|
|
|
|
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
|
|
|
|
if (parsed && typeof parsed === "object") {
|
|
|
|
|
|
for (const [id, info] of Object.entries(parsed)) {
|
|
|
|
|
|
if (!map[id]) {
|
|
|
|
|
|
map[id] = { label: "", hostname: "" };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (info?.label && !map[id].label) map[id].label = info.label;
|
|
|
|
|
|
if (info?.hostname && !map[id].hostname) map[id].hostname = info.hostname;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) 告警记录里反查 device_id → device_name(label)
|
|
|
|
|
|
if (alertRecordStore) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const alerts = alertRecordStore.listAlerts(readStorage, { limit: 500 });
|
|
|
|
|
|
for (const a of alerts || []) {
|
|
|
|
|
|
if (a?.device_id && a?.device_name && !map[a.device_id]?.label) {
|
|
|
|
|
|
map[a.device_id] = map[a.device_id] || {};
|
|
|
|
|
|
map[a.device_id].label = a.device_name;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3) 巡检结果里反查(有些 result 也带 device_name)
|
|
|
|
|
|
if (inspectionResultStore) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const all = inspectionResultStore.getLatestAll(readStorage);
|
|
|
|
|
|
for (const r of all || []) {
|
|
|
|
|
|
if (!r?.device_id) continue;
|
|
|
|
|
|
map[r.device_id] = map[r.device_id] || {};
|
|
|
|
|
|
if (r.device_name && !map[r.device_id].label) {
|
|
|
|
|
|
map[r.device_id].label = r.device_name;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r.hostname && !map[r.device_id].hostname) {
|
|
|
|
|
|
map[r.device_id].hostname = r.hostname;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readStorage(key) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = _storage?.readStorage?.(key);
|
|
|
|
|
|
if (raw === null || raw === undefined) return null;
|
|
|
|
|
|
// 原样返回磁盘上的值:可能是 string(JSON 字符串)、对象、数组
|
|
|
|
|
|
// 不再额外 JSON.stringify——让下游 store 自己决定要不要 parse
|
|
|
|
|
|
return raw;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 路由 ────
|
|
|
|
|
|
async function handleApi(pathname, method, body, query) {
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// /api/settings 路由不依赖 modules,先快速处理
|
|
|
|
|
|
if (pathname === "/api/settings") {
|
|
|
|
|
|
console.log("[RemoteHTTP]", method, "/api/settings", "body:", typeof body === "string" ? body?.substring(0, 200) : JSON.stringify(body));
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (method === "GET") {
|
|
|
|
|
|
const all = _storage?.readStorage?.("__settings__all__") || {};
|
|
|
|
|
|
let parsed = {};
|
|
|
|
|
|
try { parsed = typeof all === "string" ? JSON.parse(all) : (all || {}); } catch {}
|
|
|
|
|
|
return { ok: true, settings: parsed };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (method === "POST") {
|
|
|
|
|
|
let parsedBody;
|
|
|
|
|
|
try { parsedBody = JSON.parse(body || "{}"); } catch (e) {
|
|
|
|
|
|
console.warn("[RemoteHTTP] POST /api/settings bad JSON:", body);
|
|
|
|
|
|
return { ok: false, error: "invalid JSON: " + e.message };
|
|
|
|
|
|
}
|
|
|
|
|
|
const entries = parsedBody.entries || parsedBody;
|
|
|
|
|
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
|
|
|
|
|
console.warn("[RemoteHTTP] POST /api/settings bad entries:", typeof entries);
|
|
|
|
|
|
return { ok: false, error: "body must be object of entries" };
|
|
|
|
|
|
}
|
|
|
|
|
|
let current = {};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = _storage?.readStorage?.("__settings__all__");
|
|
|
|
|
|
current = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : {};
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
const merged = { ...current, ...entries };
|
|
|
|
|
|
try { _storage?.writeStorage?.("__settings__all__", JSON.stringify(merged)); } catch (e) {
|
|
|
|
|
|
console.warn("[RemoteHTTP] POST /api/settings writeStorage error:", e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
try { _onSettingsChanged?.(); } catch {}
|
|
|
|
|
|
console.log("[RemoteHTTP] POST /api/settings OK, keys:", Object.keys(entries).length);
|
|
|
|
|
|
return { ok: true, merged };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (method === "DELETE") {
|
|
|
|
|
|
let parsedBody;
|
|
|
|
|
|
try { parsedBody = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON" }; }
|
|
|
|
|
|
const keys = Array.isArray(parsedBody.keys) ? parsedBody.keys : [];
|
|
|
|
|
|
if (!keys.length) return { ok: false, error: "keys array required" };
|
|
|
|
|
|
let current = {};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = _storage?.readStorage?.("__settings__all__");
|
|
|
|
|
|
current = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : {};
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
for (const k of keys) delete current[k];
|
|
|
|
|
|
try { _storage?.writeStorage?.("__settings__all__", JSON.stringify(current)); } catch {}
|
|
|
|
|
|
try { _onSettingsChanged?.(); } catch {}
|
|
|
|
|
|
return { ok: true, remaining: current };
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: false, error: "method not allowed" };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("[RemoteHTTP] /api/settings error:", e);
|
|
|
|
|
|
return { ok: false, error: String(e?.message || e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
const modules = loadModules();
|
|
|
|
|
|
// 每次请求前重置所有 store 缓存,确保读到磁盘最新数据
|
|
|
|
|
|
resetAllCaches(modules);
|
|
|
|
|
|
const { deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore } = modules;
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/status —— 全局概览
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/status") {
|
|
|
|
|
|
const extMap = deviceStore.getAllExtensions(readStorage);
|
|
|
|
|
|
const extArr = Object.values(extMap);
|
|
|
|
|
|
const enabled = extArr.filter((e) => e.inspection_enabled).length;
|
|
|
|
|
|
const disabled = extArr.length - enabled;
|
|
|
|
|
|
const latestAll = inspectionResultStore.getLatestAll(readStorage);
|
|
|
|
|
|
const okCnt = latestAll.filter((r) => r?.ok).length;
|
|
|
|
|
|
const failCnt = latestAll.filter((r) => r && !r.ok).length;
|
|
|
|
|
|
const alerts = alertRecordStore.listAlerts(readStorage, {
|
|
|
|
|
|
status: "active",
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
});
|
|
|
|
|
|
const critical = alerts.filter((a) => a.level === "critical" || a.level === 3).length;
|
|
|
|
|
|
const warning = alerts.filter((a) => a.level === "warning" || a.level === 2).length;
|
|
|
|
|
|
const info = alerts.filter((a) => a.level === "info" || a.level === 1).length;
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
|
devices: { total: extArr.length, enabled, disabled },
|
|
|
|
|
|
latestScan: { total: latestAll.length, ok: okCnt, fail: failCnt },
|
|
|
|
|
|
alerts: { total: alerts.length, critical, warning, info },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/devices —— 设备列表 + 巡检状态
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/devices") {
|
|
|
|
|
|
const extMap = deviceStore.getAllExtensions(readStorage);
|
|
|
|
|
|
const hostMap = buildHostLabelMap(readStorage, { alertRecordStore, inspectionResultStore });
|
|
|
|
|
|
const latestMap = {};
|
|
|
|
|
|
const all = inspectionResultStore.getLatestAll(readStorage);
|
|
|
|
|
|
for (const r of all) if (r?.device_id) latestMap[r.device_id] = r;
|
|
|
|
|
|
|
|
|
|
|
|
// catalog_id → 业务类型(switch/server/firewall)
|
|
|
|
|
|
function catalogTypeToDeviceType(catalogId) {
|
|
|
|
|
|
if (!catalogId) return "server";
|
|
|
|
|
|
if (/switch/i.test(catalogId)) return "switch";
|
|
|
|
|
|
if (/firewall/i.test(catalogId)) return "firewall";
|
|
|
|
|
|
if (/router/i.test(catalogId)) return "router";
|
|
|
|
|
|
return "server";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const list = Object.values(extMap).map((ext) => {
|
|
|
|
|
|
const r = latestMap[ext.host_id];
|
|
|
|
|
|
const hi = hostMap[ext.host_id] || {};
|
|
|
|
|
|
const label = hi.label || "";
|
|
|
|
|
|
const hostname = hi.hostname || ext.hostname || "";
|
|
|
|
|
|
const name = label || hostname || ext.host_id;
|
|
|
|
|
|
const catalogType = ext.catalog_id || "";
|
|
|
|
|
|
const deviceType = catalogTypeToDeviceType(catalogType);
|
|
|
|
|
|
return {
|
|
|
|
|
|
host_id: ext.host_id,
|
|
|
|
|
|
label,
|
|
|
|
|
|
hostname,
|
|
|
|
|
|
name,
|
|
|
|
|
|
catalog_id: catalogType,
|
|
|
|
|
|
catalog_type: catalogType, // 原始 catalog id(huawei_h3c_switch/linux 等)
|
|
|
|
|
|
distro: catalogType, // 让 classifyHostType 通过 distro 字段能匹配到
|
|
|
|
|
|
type: deviceType, // switch/server/firewall/router
|
|
|
|
|
|
catalog: catalogType,
|
|
|
|
|
|
tier: ext.tier,
|
|
|
|
|
|
inspection_enabled: ext.inspection_enabled ?? null,
|
|
|
|
|
|
inspection_interval: ext.inspection_interval ?? null,
|
|
|
|
|
|
interval_minutes: ext.inspection_interval || 30,
|
|
|
|
|
|
last_scan_at: ext.last_scan_at || r?.scanned_at || null,
|
|
|
|
|
|
last_ok: r?.ok ?? null,
|
|
|
|
|
|
last_error: r?.ok === false ? (r?.error || "") : null,
|
|
|
|
|
|
metrics: r?.metrics || {},
|
|
|
|
|
|
online: r?.online ?? true,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
return { ok: true, devices: list };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/alerts —— 活跃告警列表
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/alerts") {
|
|
|
|
|
|
const alerts = alertRecordStore.listAlerts(readStorage, {
|
|
|
|
|
|
status: "active",
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
});
|
|
|
|
|
|
return { ok: true, alerts };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/alert-rules —— 告警规则列表
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/alert-rules") {
|
|
|
|
|
|
const rules = alertRuleStore.listRules(readStorage);
|
|
|
|
|
|
return { ok: true, rules };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// POST /api/alert/:id/acknowledge —— 确认告警
|
|
|
|
|
|
const alertActionMatch = pathname.match(/^\/api\/alert\/([\w-]+)\/(acknowledge|resolve|close|note)$/);
|
|
|
|
|
|
if (method === "POST" && alertActionMatch) {
|
|
|
|
|
|
const alertId = alertActionMatch[1];
|
|
|
|
|
|
const action = alertActionMatch[2];
|
|
|
|
|
|
let payload;
|
|
|
|
|
|
try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
|
|
|
|
|
|
const writeFn = (k, v) => _storage?.writeStorage?.(k, v);
|
|
|
|
|
|
try {
|
|
|
|
|
|
let result;
|
|
|
|
|
|
if (action === "acknowledge") {
|
|
|
|
|
|
result = alertRecordStore.acknowledgeAlert(readStorage, writeFn, alertId, payload.operator || "remote");
|
|
|
|
|
|
} else if (action === "resolve") {
|
|
|
|
|
|
result = alertRecordStore.resolveAlert(readStorage, writeFn, alertId, payload.reason || "manual", payload.operator || "remote");
|
|
|
|
|
|
} else if (action === "close") {
|
|
|
|
|
|
result = alertRecordStore.closeAlert(readStorage, writeFn, alertId, payload.note || "");
|
|
|
|
|
|
} else if (action === "note") {
|
|
|
|
|
|
result = alertRecordStore.updateNote(readStorage, writeFn, alertId, payload.note || "");
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: true, alert: result };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// GET /api/inspection/results —— 所有设备最新巡检结果(完整结构,monitoring 页面用)
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/inspection/results") {
|
|
|
|
|
|
const all = inspectionResultStore.getLatestAll(readStorage);
|
|
|
|
|
|
return { ok: true, results: Object.values(all) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// GET /api/inspection/results/recent —— 最近巡检结果(支持 query.limit,默认 500 覆盖 7 天历史)
|
2026-10-19 21:11:57 +08:00
|
|
|
|
if (method === "GET" && pathname === "/api/inspection/results/recent") {
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const limit = Math.min(parseInt(query.limit, 10) || 500, 5000);
|
|
|
|
|
|
const all = inspectionResultStore.getRecent(readStorage, limit);
|
2026-10-19 21:11:57 +08:00
|
|
|
|
return { ok: true, results: all };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// GET /api/inspection/results/range —— 按时间范围查询巡检历史(工作台柱状图用)
|
|
|
|
|
|
// 查询参数: start=timestamp&end=timestamp
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/inspection/results/range") {
|
|
|
|
|
|
const start = parseInt(query.start, 10);
|
|
|
|
|
|
const end = parseInt(query.end, 10);
|
|
|
|
|
|
if (!start || !end || start >= end) {
|
|
|
|
|
|
return { ok: false, error: "Invalid start/end" };
|
|
|
|
|
|
}
|
|
|
|
|
|
const results = inspectionResultStore.getByTimeRange(readStorage, start, end);
|
|
|
|
|
|
return { ok: true, results };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 17:00:38 +08:00
|
|
|
|
// ════════════════════════════════════════════════════════
|
|
|
|
|
|
// v1.3 预案管理路由(远程浏览器访问)
|
|
|
|
|
|
// ════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/plan-templates —— 预案模板列表
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/plan-templates") {
|
|
|
|
|
|
return { ok: true, templates: planTemplateStore.listTemplates(readStorage) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/device-plans —— 设备预案列表(带可选 filter 查询参数)
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/device-plans") {
|
|
|
|
|
|
const filters = {};
|
|
|
|
|
|
if (query.status) filters.status = query.status;
|
|
|
|
|
|
if (query.level) filters.level = query.level;
|
|
|
|
|
|
if (query.device_type) filters.device_type = query.device_type;
|
|
|
|
|
|
return { ok: true, plans: devicePlanStore.listPlans(readStorage, filters) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/device-plans/:id —— 单条预案详情
|
|
|
|
|
|
const planMatch = pathname.match(/^\/api\/device-plans\/([^/]+)$/);
|
|
|
|
|
|
if (method === "GET" && planMatch) {
|
|
|
|
|
|
const plan = devicePlanStore.getPlan(readStorage, decodeURIComponent(planMatch[1]));
|
|
|
|
|
|
if (!plan) return { ok: false, error: "Plan not found" };
|
|
|
|
|
|
return { ok: true, plan };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/command-catalog —— 预置命令目录
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/command-catalog") {
|
|
|
|
|
|
return { ok: true, commands: commandCatalogStore.listCommands(readStorage) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/aggregation-rules —— 聚合规则
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/aggregation-rules") {
|
|
|
|
|
|
return { ok: true, rules: aggregationEngine.listRules(readStorage) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// GET /api/devices/enabled —— 已启用巡检的设备列表
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/devices/enabled") {
|
|
|
|
|
|
const enabled = deviceStore.getEnabledDevices(readStorage);
|
|
|
|
|
|
return { ok: true, devices: enabled };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/topology-positions —— Electron 拖拽保存的拓扑位置(远程浏览器同步)
|
|
|
|
|
|
// 直接读磁盘,绕过 inspectionStorage 内存缓存
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/topology-positions") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fs = require("fs");
|
|
|
|
|
|
const path = require("path");
|
|
|
|
|
|
const app = require("electron").app;
|
|
|
|
|
|
const storeFile = path.join(app.getPath("userData"), "inspection-data.json");
|
|
|
|
|
|
if (!fs.existsSync(storeFile)) return { ok: true, positions: {} };
|
|
|
|
|
|
const all = JSON.parse(fs.readFileSync(storeFile, "utf-8") || "{}");
|
|
|
|
|
|
return { ok: true, positions: all._topology_positions || {} };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: true, positions: {} };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/dashboard-title —— 监控大屏标题 + 产品名 + 报告产品名(远程浏览器同步)
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/dashboard-title") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { inspectionStorage } = loadModules();
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
dashboardTitle: inspectionStorage.readStorage("_dashboard_title") || "",
|
|
|
|
|
|
productName: inspectionStorage.readStorage("_product_name") || "",
|
|
|
|
|
|
reportProductName: inspectionStorage.readStorage("_report_product_name") || "",
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return { ok: true, dashboardTitle: "", productName: "", reportProductName: "" };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/topology-links —— 用户手动添加的连线
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/topology-links") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fs = require("fs");
|
|
|
|
|
|
const path = require("path");
|
|
|
|
|
|
const app = require("electron").app;
|
|
|
|
|
|
const storeFile = path.join(app.getPath("userData"), "inspection-data.json");
|
|
|
|
|
|
if (!fs.existsSync(storeFile)) return { ok: true, links: [] };
|
|
|
|
|
|
const all = JSON.parse(fs.readFileSync(storeFile, "utf-8") || "{}");
|
|
|
|
|
|
return { ok: true, links: all._topology_links || [] };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return { ok: true, links: [] };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/inspection/scheduler-status —— 调度器状态(简化版)
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/inspection/scheduler-status") {
|
|
|
|
|
|
// 从 inspection-data.json 读调度器状态
|
|
|
|
|
|
try {
|
|
|
|
|
|
const storeFile = path.join(
|
|
|
|
|
|
require("electron").app.getPath("userData"),
|
|
|
|
|
|
"inspection-data.json"
|
|
|
|
|
|
);
|
|
|
|
|
|
if (fs.existsSync(storeFile)) {
|
|
|
|
|
|
const all = JSON.parse(fs.readFileSync(storeFile, "utf-8") || "{}");
|
|
|
|
|
|
const sched = all["inspection-scheduler"] || {};
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
running: !!sched.enabled,
|
|
|
|
|
|
queue_size: 0,
|
|
|
|
|
|
active_scans: 0,
|
|
|
|
|
|
concurrency: sched.concurrency || 3,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
return { ok: true, running: false, queue_size: 0, active_scans: 0, concurrency: 3 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/inspection/catalog —— 设备类型目录
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/inspection/catalog") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const catalog = deviceStore.getCatalog();
|
|
|
|
|
|
return { ok: true, catalog };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("[RemoteHTTP] catalog error:", e.message);
|
|
|
|
|
|
return { ok: true, catalog: [] };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/inspection/sys-config —— 系统配置
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/inspection/sys-config") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const cfg = deviceStore.getSysConfig(readStorage);
|
|
|
|
|
|
return { ok: true, data: cfg };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return { ok: true, data: {} };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/inspection/labels —— 标签字典
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/inspection/labels") {
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
data: {
|
|
|
|
|
|
alert_levels: { critical: "严重", warning: "警告", info: "提示" },
|
|
|
|
|
|
device_tiers: { core: "核心", aggregation: "汇聚", access: "接入" },
|
|
|
|
|
|
inspection_status: { success: "成功", failed: "失败", timeout: "超时" },
|
|
|
|
|
|
alert_record_status: { active: "活跃", acknowledged: "已确认", resolved: "已解决", closed: "已关闭" },
|
|
|
|
|
|
operators: { ">": ">", "<": "<", "==": "=", "!=": "!=", ">=": ">=", "<=": "<=" },
|
|
|
|
|
|
device_catalog: {},
|
|
|
|
|
|
metrics: {},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/alert-stats —— 告警统计
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/alert-stats") {
|
|
|
|
|
|
const all = alertRecordStore.listAlerts(readStorage, { limit: 500 });
|
|
|
|
|
|
const active = all.filter((a) => a.status === "active");
|
|
|
|
|
|
const acknowledged = all.filter((a) => a.status === "acknowledged");
|
|
|
|
|
|
const resolved = all.filter((a) => a.status === "resolved");
|
|
|
|
|
|
const closed = all.filter((a) => a.status === "closed");
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
data: {
|
|
|
|
|
|
total: all.length,
|
|
|
|
|
|
active: active.length,
|
|
|
|
|
|
active_critical: active.filter((a) => a.level === "critical").length,
|
|
|
|
|
|
active_warning: active.filter((a) => a.level === "warning").length,
|
|
|
|
|
|
active_info: active.filter((a) => a.level === "info").length,
|
|
|
|
|
|
acknowledged: acknowledged.length,
|
|
|
|
|
|
resolved: resolved.length,
|
|
|
|
|
|
closed: closed.length,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/snapshots —— 巡检快照列表
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/snapshots") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const storeFile = path.join(
|
|
|
|
|
|
require("electron").app.getPath("userData"),
|
|
|
|
|
|
"inspection-data.json"
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!fs.existsSync(storeFile)) return { ok: true, snapshots: [] };
|
|
|
|
|
|
const all = JSON.parse(fs.readFileSync(storeFile, "utf-8") || "{}");
|
|
|
|
|
|
const snaps = (all.snapshots || []).slice(-30).reverse();
|
|
|
|
|
|
return { ok: true, snapshots: snaps };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e.message, snapshots: [] };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/report/snapshots —— 巡检报告快照列表(走 reportStore)
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/report/snapshots") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { listSnapshots } = require("./bridges/inspection/reportStore.cjs");
|
|
|
|
|
|
const list = listSnapshots(readStorage, 50);
|
|
|
|
|
|
return { ok: true, snapshots: list || [] };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: true, snapshots: [] };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/report/snapshot-status —— 是否有快照在运行
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/report/snapshot-status") {
|
|
|
|
|
|
return { ok: true, running: false }; // 远程只读场景不允许执行快照
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/report/schedule —— 报告调度配置
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/report/schedule") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { getSchedule } = require("./bridges/inspection/reportScheduleStore.cjs");
|
|
|
|
|
|
const cfg = getSchedule(readStorage);
|
|
|
|
|
|
return { ok: true, schedule: cfg || null };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return { ok: true, schedule: null };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/daily-reports —— 日运行报告列表
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/daily-reports") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { listDailyReports } = require("./bridges/inspection/dailyReportStore.cjs");
|
|
|
|
|
|
const list = listDailyReports(readStorage, 30);
|
|
|
|
|
|
return { ok: true, reports: list || [] };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: true, reports: [] };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/monitoring —— 运行监控聚合数据(renderer fallback 用)
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/monitoring") {
|
|
|
|
|
|
const extMap = deviceStore.getAllExtensions(readStorage);
|
|
|
|
|
|
const hostMap = buildHostLabelMap(readStorage, { alertRecordStore, inspectionResultStore });
|
|
|
|
|
|
const extArr = Object.values(extMap);
|
|
|
|
|
|
const latestAll = inspectionResultStore.getLatestAll(readStorage);
|
|
|
|
|
|
const latestMap = {};
|
|
|
|
|
|
for (const r of latestAll) if (r?.device_id) latestMap[r.device_id] = r;
|
|
|
|
|
|
|
|
|
|
|
|
const alerts = alertRecordStore.listAlerts(readStorage, {
|
|
|
|
|
|
status: "active",
|
|
|
|
|
|
limit: 200,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const rules = alertRuleStore.listRules(readStorage);
|
|
|
|
|
|
|
|
|
|
|
|
// 每台设备的 metrics(cpu_usage / memory_usage / online)和巡检状态
|
|
|
|
|
|
const devices = extArr.map((ext) => {
|
|
|
|
|
|
const r = latestMap[ext.host_id];
|
|
|
|
|
|
const m = r?.metrics || {};
|
|
|
|
|
|
const hi = hostMap[ext.host_id] || {};
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: ext.host_id,
|
|
|
|
|
|
name: hi.label || hi.hostname || ext.hostname || ext.host_id,
|
|
|
|
|
|
catalog_id: ext.catalog_id,
|
|
|
|
|
|
tier: ext.tier,
|
|
|
|
|
|
inspection_enabled: !!ext.inspection_enabled,
|
|
|
|
|
|
last_scan_at: ext.last_scan_at || r?.timestamp || null,
|
|
|
|
|
|
online: r?.online ?? true,
|
|
|
|
|
|
cpu_usage: Number(m.cpu_usage ?? m.cpu_percent ?? m.cpu ?? 0),
|
|
|
|
|
|
memory_usage: Number(m.memory_usage ?? m.mem_percent ?? m.memory ?? 0),
|
|
|
|
|
|
status: r?.status || null,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
devices,
|
|
|
|
|
|
alerts,
|
|
|
|
|
|
rules,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/devices/batch-enable
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/devices/batch-enable") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = JSON.parse(body || "{}");
|
|
|
|
|
|
const enabled = payload.enabled !== false;
|
|
|
|
|
|
const all = deviceStore.getAllExtensions(readStorage);
|
|
|
|
|
|
let count = 0;
|
|
|
|
|
|
for (const ext of Object.values(all)) {
|
|
|
|
|
|
deviceStore.upsertExtension(readStorage, (k, v) => _storage?.writeStorage?.(k, v), ext.host_id, {
|
|
|
|
|
|
inspection_enabled: enabled,
|
|
|
|
|
|
});
|
|
|
|
|
|
count++;
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: true, count, enabled };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e.message };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/vault/:key —— Vault 子页面通用只读路由
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// 支持 hosts/keys/proxies/port-forwards/snippets/notes/known-hosts/connection-logs/groups/identities
|
|
|
|
|
|
// 敏感数据(keys/identities/hosts)自动脱敏
|
2026-10-19 21:11:57 +08:00
|
|
|
|
const _VAULT_ROUTE_MAP = {
|
2026-10-30 12:12:04 +08:00
|
|
|
|
hosts: "_vault_hosts",
|
2026-10-19 21:11:57 +08:00
|
|
|
|
keys: "_vault_keys",
|
|
|
|
|
|
proxies: "_vault_proxies",
|
|
|
|
|
|
"port-forwards": "_vault_port_forwards",
|
|
|
|
|
|
snippets: "_vault_snippets",
|
|
|
|
|
|
notes: "_vault_notes",
|
|
|
|
|
|
"known-hosts": "_vault_known_hosts",
|
|
|
|
|
|
"connection-logs": "_vault_connection_logs",
|
|
|
|
|
|
groups: "_vault_groups",
|
|
|
|
|
|
identities: "_vault_identities",
|
|
|
|
|
|
};
|
|
|
|
|
|
const _SENSITIVE_FIELDS = new Set(["privateKeyContent", "privateKey", "passphrase", "password", "secret", "apiKey", "private_key", "pass_phrase"]);
|
|
|
|
|
|
function _stripSensitive(obj) {
|
|
|
|
|
|
if (obj === null || obj === undefined) return obj;
|
|
|
|
|
|
if (Array.isArray(obj)) return obj.map(_stripSensitive);
|
|
|
|
|
|
if (typeof obj === "object") {
|
|
|
|
|
|
const out = {};
|
|
|
|
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
|
|
|
|
if (_SENSITIVE_FIELDS.has(k)) continue;
|
|
|
|
|
|
out[k] = _stripSensitive(v);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
return obj;
|
|
|
|
|
|
}
|
|
|
|
|
|
const vaultMatch = pathname.match(/^\/api\/vault\/([\w-]+)$/);
|
|
|
|
|
|
if (method === "GET" && vaultMatch) {
|
|
|
|
|
|
const key = vaultMatch[1];
|
|
|
|
|
|
const storageKey = _VAULT_ROUTE_MAP[key];
|
|
|
|
|
|
if (!storageKey) return { ok: false, error: "unknown vault key" };
|
|
|
|
|
|
try {
|
|
|
|
|
|
resetAllCaches();
|
|
|
|
|
|
const raw = _storage?.readStorage?.(storageKey);
|
|
|
|
|
|
let data = raw;
|
|
|
|
|
|
if (typeof raw === "string") {
|
|
|
|
|
|
try { data = JSON.parse(raw); } catch { data = raw; }
|
|
|
|
|
|
}
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// keys、identities 和 hosts 需要脱敏(hosts 里可能带 password)
|
|
|
|
|
|
if (key === "keys" || key === "identities" || key === "hosts") {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
data = _stripSensitive(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: true, data: data ?? (Array.isArray(data) ? [] : {}) };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// POST /api/alert-rules —— 批量保存告警规则(远程浏览器编辑用)
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/alert-rules") {
|
|
|
|
|
|
let rules;
|
|
|
|
|
|
try { rules = JSON.parse(body || "[]"); } catch { return { ok: false, error: "invalid JSON body" }; }
|
|
|
|
|
|
if (!Array.isArray(rules)) return { ok: false, error: "rules must be an array" };
|
|
|
|
|
|
try {
|
|
|
|
|
|
alertRuleStore.saveRules(readStorage, (k, v) => _storage?.writeStorage?.(k, v), rules);
|
|
|
|
|
|
return { ok: true, count: rules.length };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/alert-rule —— 单条 create/update/delete/toggle
|
|
|
|
|
|
// body: { action: 'create'|'update'|'delete'|'toggle', ...fields }
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/alert-rule") {
|
|
|
|
|
|
let payload;
|
|
|
|
|
|
try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
|
|
|
|
|
|
const write = (k, v) => _storage?.writeStorage?.(k, v);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (payload.action === "create") {
|
|
|
|
|
|
const rule = alertRuleStore.createRule(readStorage, write, payload.data || payload);
|
|
|
|
|
|
return { ok: true, rule };
|
|
|
|
|
|
} else if (payload.action === "update") {
|
|
|
|
|
|
const rule = alertRuleStore.updateRule(readStorage, write, payload.id, payload.updates || payload.data);
|
|
|
|
|
|
return { ok: true, rule };
|
|
|
|
|
|
} else if (payload.action === "delete") {
|
|
|
|
|
|
const ok = alertRuleStore.deleteRule(readStorage, write, payload.id);
|
|
|
|
|
|
return { ok: !!ok };
|
|
|
|
|
|
} else if (payload.action === "toggle") {
|
|
|
|
|
|
const rule = alertRuleStore.toggleRule(readStorage, write, payload.id, !!payload.enabled);
|
|
|
|
|
|
return { ok: true, rule };
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: false, error: "unknown action" };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/report/schedule —— 保存报告调度配置
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/report/schedule") {
|
|
|
|
|
|
let config;
|
|
|
|
|
|
try { config = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { reportScheduleStore } = loadModules();
|
|
|
|
|
|
const merged = reportScheduleStore.saveSchedule((k, v) => _storage?.writeStorage?.(k, v), config);
|
|
|
|
|
|
return { ok: true, schedule: merged };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/report/scheduler-toggle —— 开启/关闭自动调度
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/report/scheduler-toggle") {
|
|
|
|
|
|
let payload;
|
|
|
|
|
|
try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
|
|
|
|
|
|
const enabled = payload.enabled !== false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { reportScheduleStore, reportScheduler } = loadModules();
|
|
|
|
|
|
const current = reportScheduleStore.getSchedule(readStorage);
|
|
|
|
|
|
const merged = reportScheduleStore.saveSchedule((k, v) => _storage?.writeStorage?.(k, v), { ...current, enabled });
|
|
|
|
|
|
// 尝试触发调度器启停(远程模式下调度器可能不存在,try/catch 兜底)
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (enabled && reportScheduler?.start) reportScheduler.start();
|
|
|
|
|
|
else if (!enabled && reportScheduler?.stop) reportScheduler.stop();
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
return { ok: true, running: enabled, schedule: merged };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GET /api/report/scheduler-status —— 调度器状态
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/report/scheduler-status") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { reportScheduleStore } = loadModules();
|
|
|
|
|
|
const schedule = reportScheduleStore.getSchedule(readStorage);
|
|
|
|
|
|
return { ok: true, running: !!schedule.enabled, schedule };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/inspection/sys-config —— 更新系统配置
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/inspection/sys-config") {
|
|
|
|
|
|
let updates;
|
|
|
|
|
|
try { updates = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { deviceStore } = loadModules();
|
|
|
|
|
|
deviceStore.updateSysConfig(readStorage, (k, v) => _storage?.writeStorage?.(k, v), updates);
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/devices/:hostId/enabled —— 单台设备巡检开关
|
|
|
|
|
|
const enableMatch = pathname.match(/^\/api\/devices\/([\w-]+)\/enabled$/);
|
|
|
|
|
|
if (method === "POST" && enableMatch) {
|
|
|
|
|
|
const hostId = enableMatch[1];
|
|
|
|
|
|
let enabled = true;
|
|
|
|
|
|
try { enabled = JSON.parse(body || "{}").enabled !== false; } catch {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { deviceStore } = loadModules();
|
|
|
|
|
|
deviceStore.upsertExtension(readStorage, (k, v) => _storage?.writeStorage?.(k, v), hostId, { inspection_enabled: enabled });
|
|
|
|
|
|
return { ok: true, hostId, enabled };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// GET /api/report-preview —— 远程浏览器动态生成 PDF 并返回流
|
|
|
|
|
|
// query: mode=snapshot|daily&id=<snapshotId|reportId>
|
|
|
|
|
|
if (method === "GET" && pathname === "/api/report-preview") {
|
|
|
|
|
|
const mode = query.mode;
|
|
|
|
|
|
const id = query.id;
|
|
|
|
|
|
if (!mode || !id) return { ok: false, error: "mode and id required" };
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { app } = require("electron");
|
|
|
|
|
|
const reportStore = require("./bridges/inspection/reportStore.cjs");
|
|
|
|
|
|
const dailyReportStore = require("./bridges/inspection/dailyReportStore.cjs");
|
|
|
|
|
|
const reportExport = require("./bridges/inspection/reportExport.cjs");
|
|
|
|
|
|
const fs = require("fs");
|
|
|
|
|
|
const path = require("path");
|
|
|
|
|
|
const inspectionStorage = require("./bridges/inspection/inspectionStorage.cjs");
|
|
|
|
|
|
|
|
|
|
|
|
// 读产品名
|
|
|
|
|
|
const storageFile = path.join(app.getPath("userData"), "inspection-data.json");
|
|
|
|
|
|
let systemName = "NetMesh 巡检报告";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const all = JSON.parse(fs.readFileSync(storageFile, "utf-8") || "{}");
|
|
|
|
|
|
systemName = all._product_name || all._dashboard_title || systemName;
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
let filePath;
|
|
|
|
|
|
if (mode === "snapshot") {
|
|
|
|
|
|
reportStore._resetForTest?.();
|
|
|
|
|
|
const snap = reportStore.getSnapshot(readStorage, id);
|
|
|
|
|
|
if (!snap) return { ok: false, error: "快照不存在" };
|
|
|
|
|
|
filePath = await reportExport.exportSnapshotPdf(app, snap, snap.ai_summary || undefined, null, systemName);
|
|
|
|
|
|
} else if (mode === "daily") {
|
|
|
|
|
|
dailyReportStore._resetForTest?.();
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const allReports = dailyReportStore.listDailyReports(readStorage, 500);
|
|
|
|
|
|
const report = (allReports || []).find((r) => r?.id === id);
|
2026-10-19 21:11:57 +08:00
|
|
|
|
if (!report) return { ok: false, error: "日报告不存在" };
|
|
|
|
|
|
reportStore._resetForTest?.();
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const allSnapshots = reportStore.listSnapshots(readStorage, 500);
|
|
|
|
|
|
const pad = (n) => n.toString().padStart(2, "0");
|
|
|
|
|
|
const daySnaps = (allSnapshots || []).filter((s) => {
|
|
|
|
|
|
const d = new Date(s.timestamp);
|
|
|
|
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` === report.date;
|
|
|
|
|
|
});
|
|
|
|
|
|
filePath = await reportExport.exportDailyReportPdf(app, report, null, daySnaps, systemName);
|
2026-10-19 21:11:57 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
return { ok: false, error: "未知 mode" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(filePath)) return { ok: false, error: "PDF 生成失败" };
|
|
|
|
|
|
const stat = fs.statSync(filePath);
|
|
|
|
|
|
const content = fs.readFileSync(filePath);
|
|
|
|
|
|
// 异步清理临时 PDF(30 秒后)
|
|
|
|
|
|
setTimeout(() => { try { fs.unlinkSync(filePath); } catch {} }, 30000);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
__raw: true,
|
|
|
|
|
|
writeHead: (res) => {
|
|
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
|
"Content-Type": "application/pdf",
|
|
|
|
|
|
"Content-Length": stat.size,
|
|
|
|
|
|
"Cache-Control": "no-cache",
|
|
|
|
|
|
"Content-Disposition": `inline; filename="${path.basename(filePath)}"`,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
end: (res) => res.end(content),
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("[report-preview] 失败:", e.message);
|
|
|
|
|
|
return { ok: false, error: e.message };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/run-report-ai-summary —— 巡检报告 AI 汇总(异步 setImmediate 执行)
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/run-report-ai-summary") {
|
|
|
|
|
|
let snapshotId, providerId;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const bodyObj = typeof body === "string" ? JSON.parse(body || "{}") : (body || {});
|
|
|
|
|
|
snapshotId = bodyObj.snapshotId;
|
|
|
|
|
|
providerId = bodyObj.providerId;
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
if (!snapshotId) return { ok: false, error: "snapshotId required" };
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 触发方式:让已注册的 IPC handler 自己被调用
|
|
|
|
|
|
// index.cjs 里 registerInspectionHandlers 注册了 "inspection:runReportAiSummary"
|
|
|
|
|
|
// 我们无法直接 require 到内部函数,但可以用 event sender = null 来触发 handler
|
|
|
|
|
|
setImmediate(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { ipcMain } = require("electron");
|
|
|
|
|
|
// ipcMain handlers 不支持 emit 触发,改用 direct call
|
|
|
|
|
|
// 直接 require 并调用 reportStore + aiBridge 自行实现
|
|
|
|
|
|
const reportStore = require("./bridges/inspection/reportStore.cjs");
|
|
|
|
|
|
const bridge = require("./bridges/aiBridge.cjs");
|
|
|
|
|
|
if (!bridge.providerConfigs?.length) {
|
|
|
|
|
|
console.warn("[RemoteHTTP] run-report-ai-summary: 无 AI provider 配置");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const readStorageFn = (k) => { try { return _storage?.readStorage?.(k); } catch { return null; } };
|
|
|
|
|
|
const writeStorageFn = (k, v) => { try { _storage?.writeStorage?.(k, v); } catch {} };
|
|
|
|
|
|
|
|
|
|
|
|
const snap = reportStore.getSnapshot(readStorageFn, snapshotId);
|
|
|
|
|
|
if (!snap) { console.warn("[RemoteHTTP] snapshot 不存在"); return; }
|
|
|
|
|
|
|
|
|
|
|
|
reportStore.setSnapshotAiStatus(readStorageFn, writeStorageFn, snapshotId, "running");
|
|
|
|
|
|
|
|
|
|
|
|
const activeProviders = bridge.providerConfigs;
|
|
|
|
|
|
const provider = activeProviders.find(p => p.id === providerId)
|
|
|
|
|
|
|| activeProviders.find(p => p.enabled !== false);
|
|
|
|
|
|
if (!provider) throw new Error("AI provider 未配置");
|
|
|
|
|
|
const resolved = bridge.resolveProviderApiKey(provider.id);
|
|
|
|
|
|
if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
|
|
|
|
|
|
|
|
|
|
|
|
// 压缩构建 prompt —— 复用 index.cjs 里的 buildSnapshotPrompt
|
|
|
|
|
|
// 但它在内部没导出,简单手动构建:
|
|
|
|
|
|
const compactDevices = (snap.devices || []).map(d => {
|
|
|
|
|
|
const cmds = (d.commands || [])
|
|
|
|
|
|
.filter(c => !c.ok) // 只留失败命令
|
|
|
|
|
|
.concat((d.commands || []).filter(c => c.ok && /cpu|mem|disk|uptime/i.test(c.command)).slice(0, 5))
|
|
|
|
|
|
.map(c => ({ cmd: c.command?.slice(0, 40) || "", ok: c.ok, out: (c.stdout || c.stderr || "").slice(0, 200) }));
|
|
|
|
|
|
return { name: d.label || d.hostname || d.host_id, type: d.device_type || d.catalog_id, ip: d.hostname, cmds };
|
|
|
|
|
|
});
|
|
|
|
|
|
const prompt = JSON.stringify({
|
|
|
|
|
|
system: "你是网络运维专家。分析以下设备巡检数据,输出 JSON:{health_summary, offline_devices[], hidden_risks[], recommendations[]}。禁止输出外网URL、厂商文档链接、外部工具推荐。",
|
|
|
|
|
|
data: { devices: compactDevices, time: new Date(snap.created_at).toISOString() }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const url = provider.endpoint || "https://api.minimaxi.com/v1/chat/completions";
|
|
|
|
|
|
const model = provider.model || "MiniMax-M1";
|
|
|
|
|
|
const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${resolved.apiKey}` };
|
|
|
|
|
|
const bodyStr = JSON.stringify({ model, messages: [{ role: "user", content: prompt }], temperature: 0.3, max_tokens: 1500 });
|
|
|
|
|
|
|
|
|
|
|
|
// HTTP fetch
|
|
|
|
|
|
const http = require("http"), https = require("https");
|
|
|
|
|
|
const lib = url.startsWith("https") ? https : http;
|
|
|
|
|
|
const u = new URL(url);
|
|
|
|
|
|
const hreq = lib.request({ hostname: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80), path: u.pathname + u.search, method: "POST", headers }, hres => {
|
|
|
|
|
|
let chunks = []; hres.on("data", c => chunks.push(c)); hres.on("end", () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = JSON.parse(Buffer.concat(chunks).toString());
|
|
|
|
|
|
const text = resp.choices?.[0]?.message?.content || "";
|
|
|
|
|
|
const js = JSON.parse(text.replace(/```json|```/g, "").trim());
|
|
|
|
|
|
reportStore.updateSnapshotAiSummary(readStorageFn, writeStorageFn, snapshotId, js);
|
|
|
|
|
|
reportStore.setSnapshotAiStatus(readStorageFn, writeStorageFn, snapshotId, "done");
|
|
|
|
|
|
console.log("[RemoteHTTP] AI 汇总完成");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
reportStore.updateSnapshotAiSummary(readStorageFn, writeStorageFn, snapshotId, null, e.message);
|
|
|
|
|
|
reportStore.setSnapshotAiStatus(readStorageFn, writeStorageFn, snapshotId, "failed", e.message);
|
|
|
|
|
|
console.error("[RemoteHTTP] AI 解析失败:", e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
hreq.on("error", e => {
|
|
|
|
|
|
reportStore.setSnapshotAiStatus(readStorageFn, writeStorageFn, snapshotId, "failed", e.message);
|
|
|
|
|
|
console.error("[RemoteHTTP] AI HTTP 错误:", e.message);
|
|
|
|
|
|
});
|
|
|
|
|
|
hreq.write(bodyStr);
|
|
|
|
|
|
hreq.end();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("[RemoteHTTP] run-report-ai-summary:", e?.message || String(e));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/generate-daily-report —— 日运行报告生成(同步)
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/generate-daily-report") {
|
|
|
|
|
|
let mode = "provisional", trigger = "manual";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const bodyObj = typeof body === "string" ? JSON.parse(body || "{}") : (body || {});
|
|
|
|
|
|
if (bodyObj.mode) mode = bodyObj.mode;
|
|
|
|
|
|
if (bodyObj.trigger) trigger = bodyObj.trigger;
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const reportScheduler = require("./bridges/inspection/reportScheduler.cjs");
|
|
|
|
|
|
const writeStorage = (k, v) => {
|
|
|
|
|
|
try { require("./bridges/inspection/inspectionStorage.cjs").setItem?.(k, v); } catch {}
|
|
|
|
|
|
};
|
|
|
|
|
|
const report = await reportScheduler.triggerDailyReport(readStorage, writeStorage, { mode, trigger });
|
|
|
|
|
|
return { ok: true, report };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/run-alert-rca —— 远程告警中心 AI 根因分析
|
|
|
|
|
|
// 委托给主进程完整 RCA:Phase 1 AI 规划 + SSH 诊断 + Phase 2 AI 综合
|
|
|
|
|
|
// 用主进程内部的 _hostConfigs 直接拿 SSH 凭据,不需要 renderer 解密
|
|
|
|
|
|
if (method === "POST" && pathname === "/api/run-alert-rca") {
|
|
|
|
|
|
let alertId;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const bodyObj = typeof body === "string" ? JSON.parse(body || "{}") : (body || {});
|
|
|
|
|
|
alertId = bodyObj.alertId;
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
if (!alertId) return { ok: false, error: "alertId required" };
|
|
|
|
|
|
try {
|
|
|
|
|
|
resetAllCaches();
|
|
|
|
|
|
const inspectionIndex = require("./bridges/inspection/index.cjs");
|
|
|
|
|
|
if (!inspectionIndex.runAlertRCA) {
|
|
|
|
|
|
return { ok: false, error: "RCA handler 未初始化(需重启 Electron 主进程)" };
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log("[RemoteHTTP] 委托主进程执行完整 RCA, alertId:", alertId);
|
|
|
|
|
|
const result = await inspectionIndex.runAlertRCA(alertId);
|
|
|
|
|
|
console.log("[RemoteHTTP] RCA 完成, success:", result?.success);
|
|
|
|
|
|
if (result?.success) {
|
|
|
|
|
|
const r = result.rca || {};
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
rca: {
|
|
|
|
|
|
summary: r.summary,
|
|
|
|
|
|
confidence: r.confidence,
|
|
|
|
|
|
rootCauses: r.rootCauses,
|
|
|
|
|
|
evidence: r.evidence,
|
|
|
|
|
|
recommendations: r.recommendations,
|
|
|
|
|
|
},
|
|
|
|
|
|
durationMs: result.durationMs,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: false, error: result?.error || "RCA 执行失败" };
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("[RemoteHTTP] RCA 异常:", e.message);
|
|
|
|
|
|
return { ok: false, error: e?.message || String(e) };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { ok: false, error: "Not found" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 主入口 ────
|
|
|
|
|
|
async function start(options) {
|
|
|
|
|
|
_storage = options?.storage;
|
2026-10-30 12:12:04 +08:00
|
|
|
|
_onSettingsChanged = options?.onSettingsChanged;
|
2026-10-19 21:11:57 +08:00
|
|
|
|
|
|
|
|
|
|
// 读配置
|
|
|
|
|
|
let enabled = false;
|
|
|
|
|
|
let port = 8848;
|
|
|
|
|
|
let savedToken = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = _storage?.readStorage?.(STORAGE_KEY);
|
|
|
|
|
|
if (raw) {
|
|
|
|
|
|
const cfg = JSON.parse(raw);
|
|
|
|
|
|
enabled = !!cfg.enabled;
|
|
|
|
|
|
port = Number(cfg.port) || 8848;
|
|
|
|
|
|
savedToken = cfg.token || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
|
|
if (!enabled) {
|
|
|
|
|
|
console.log("[RemoteHTTP] 远程访问未启用,跳过启动");
|
|
|
|
|
|
return { started: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (_server) {
|
|
|
|
|
|
console.log("[RemoteHTTP] 已在运行,端口", _port);
|
|
|
|
|
|
return { started: true, port: _port, token: _token };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_token = savedToken || crypto.randomBytes(12).toString("hex");
|
|
|
|
|
|
_port = port;
|
|
|
|
|
|
|
|
|
|
|
|
// 启动前持久化 token(如果是新生成的)
|
|
|
|
|
|
if (!savedToken) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
_storage?.writeStorage?.(STORAGE_KEY, JSON.stringify({ enabled: true, port: _port, token: _token }));
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 校验 dist 是否存在
|
|
|
|
|
|
const distDir = resolveDistDir();
|
|
|
|
|
|
const indexHtml = path.join(distDir, "index.html");
|
|
|
|
|
|
if (!fs.existsSync(indexHtml)) {
|
|
|
|
|
|
console.warn("[RemoteHTTP] dist/index.html 不存在,远程访问页面将不可用。尝试路径:", distDir);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_server = http.createServer(async (req, res) => {
|
|
|
|
|
|
const { pathname, query } = parseQuery(req.url);
|
|
|
|
|
|
|
|
|
|
|
|
// favicon.ico — 浏览器自动请求,返回 204 空响应避免 404
|
|
|
|
|
|
if (pathname === "/favicon.ico") {
|
|
|
|
|
|
res.writeHead(204);
|
|
|
|
|
|
res.end();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// /api/* —— 要 token
|
|
|
|
|
|
if (pathname.startsWith("/api/")) {
|
|
|
|
|
|
if (query.token !== _token) {
|
|
|
|
|
|
sendJson(res, 401, { ok: false, error: "Unauthorized" });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const body = await readBody(req);
|
|
|
|
|
|
const result = await handleApi(pathname, req.method || "GET", body, query);
|
|
|
|
|
|
if (result?.__raw) {
|
|
|
|
|
|
result.writeHead(res);
|
|
|
|
|
|
result.end(res);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sendJson(res, result.ok ? 200 : (result.error === "Not found" ? 404 : 400), result);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("[RemoteHTTP] API error:", e);
|
|
|
|
|
|
sendJson(res, 500, { ok: false, error: e.message });
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 静态入口(/、/index.html、任意 SPA fallback)—— 要 token,且注入 window.__REMOTE_TOKEN__
|
|
|
|
|
|
// 静态资源 /assets/* /monaco/* 不强制 token(已经在 index.html 里,token 校验在入口做)
|
|
|
|
|
|
const isAssetPath =
|
|
|
|
|
|
pathname.startsWith("/assets/") ||
|
|
|
|
|
|
pathname.startsWith("/monaco/") ||
|
|
|
|
|
|
pathname.startsWith("/fonts/") ||
|
|
|
|
|
|
pathname.startsWith("/images/") ||
|
|
|
|
|
|
pathname.startsWith("/public/") ||
|
|
|
|
|
|
pathname.endsWith(".png") ||
|
|
|
|
|
|
pathname.endsWith(".jpg") ||
|
|
|
|
|
|
pathname.endsWith(".jpeg") ||
|
|
|
|
|
|
pathname.endsWith(".svg") ||
|
|
|
|
|
|
pathname.endsWith(".ico") ||
|
|
|
|
|
|
pathname.endsWith(".woff") ||
|
|
|
|
|
|
pathname.endsWith(".woff2") ||
|
|
|
|
|
|
pathname.endsWith(".ttf") ||
|
|
|
|
|
|
pathname.endsWith(".eot") ||
|
|
|
|
|
|
pathname.endsWith(".mp3") ||
|
|
|
|
|
|
pathname.endsWith(".wasm") ||
|
|
|
|
|
|
pathname.endsWith(".map");
|
|
|
|
|
|
|
|
|
|
|
|
if (isAssetPath) {
|
|
|
|
|
|
serveStatic(res, pathname);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SPA 入口 —— 所有非API、非资源的路径都 serve index.html + token 注入
|
|
|
|
|
|
if (query.token !== _token) {
|
|
|
|
|
|
sendHtml(res, 200,
|
|
|
|
|
|
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>NetMesh</title></head>' +
|
|
|
|
|
|
'<body style="padding:40px;font-family:-apple-system,sans-serif;color:#333;">' +
|
|
|
|
|
|
'<h1>NetMesh 远程访问</h1>' +
|
|
|
|
|
|
'<p style="color:#c00;">缺少或错误的 token 参数</p>' +
|
|
|
|
|
|
'<p>请从 NetMesh 设置页获取完整 URL</p>' +
|
|
|
|
|
|
'</body></html>'
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// 读 dist/index.html 并在 </head> 前注入 token + 同步设置预加载
|
|
|
|
|
|
// 同步 XHR 确保 React SPA 启动前 localStorage 已有权威设置值
|
2026-10-19 21:11:57 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const htmlPath = path.join(resolveDistDir(), "index.html");
|
|
|
|
|
|
let html = fs.readFileSync(htmlPath, "utf-8");
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const inject = `<script>window.__REMOTE_TOKEN__=${JSON.stringify(_token)};window.__REMOTE_BASE__='/';(function(){try{var x=new XMLHttpRequest();x.open('GET','/api/settings?token='+encodeURIComponent(window.__REMOTE_TOKEN__),false);x.send();if(x.status===200){var j=JSON.parse(x.responseText);if(j.ok&&j.settings){for(var k in j.settings){if(j.settings[k]!==null&&j.settings[k]!==undefined){localStorage.setItem(k,String(j.settings[k]));}}}}}catch(e){}var dirtyKeys={};var _si=localStorage.setItem.bind(localStorage);var _ri=localStorage.removeItem.bind(localStorage);localStorage.setItem=function(k,v){var old=localStorage.getItem(k);_si(k,String(v));dirtyKeys[k]=Date.now();if(old!==String(v)){try{globalThis.__NETCATTY_SETTINGS_SYNC__&&globalThis.__NETCATTY_SETTINGS_SYNC__(k,String(v));}catch(e){}}};localStorage.removeItem=function(k){dirtyKeys[k]=Date.now();_ri(k);try{globalThis.__NETCATTY_SETTINGS_SYNC__&&globalThis.__NETCATTY_SETTINGS_SYNC__(k,null);}catch(e){}};(function poll(){try{var x=new XMLHttpRequest();x.open('GET','/api/settings?token='+encodeURIComponent(window.__REMOTE_TOKEN__),true);x.onload=function(){if(x.status===200){var j=JSON.parse(x.responseText);if(j.ok&&j.settings){var now=Date.now();for(var k in j.settings){var authVal=j.settings[k];var curVal=localStorage.getItem(k);if(authVal!==null&&authVal!==undefined){if(curVal!==String(authVal)){var dirty=dirtyKeys[k];if(!dirty||now-dirty>2000){_si(k,String(authVal));try{window.dispatchEvent(new StorageEvent('storage',{key:k,oldValue:curVal,newValue:String(authVal),url:location.href}));}catch(e){}}}}else{if(curVal!==null){var dirty=dirtyKeys[k];if(!dirty||now-dirty>2000){_ri(k);try{window.dispatchEvent(new StorageEvent('storage',{key:k,oldValue:curVal,newValue:null,url:location.href}));}catch(e){}}}}}}}};x.send();}catch(e){}setTimeout(poll,3000);})();})();</script>`;
|
2026-10-19 21:11:57 +08:00
|
|
|
|
html = html.replace("</head>", inject + "</head>");
|
|
|
|
|
|
sendHtml(res, 200, html);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
sendHtml(res, 500,
|
|
|
|
|
|
'<h1>NetMesh 远程访问</h1><p style="color:#c00;">dist/index.html 加载失败: ' + e.message + '</p>'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
_server.listen(_port, "0.0.0.0", () => {
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const lanIp = getLanIp();
|
|
|
|
|
|
const url = `http://${lanIp}:${_port}/?token=${_token}`;
|
2026-10-19 21:11:57 +08:00
|
|
|
|
console.log("[RemoteHTTP] ✅ 远程 HTTP 服务已启动");
|
|
|
|
|
|
console.log("[RemoteHTTP] 监听端口:", _port);
|
2026-10-30 12:12:04 +08:00
|
|
|
|
console.log("[RemoteHTTP] 本机 URL (localhost):", `http://localhost:${_port}/?token=${_token}`);
|
|
|
|
|
|
console.log("[RemoteHTTP] 局域网 URL:", url);
|
2026-10-19 21:11:57 +08:00
|
|
|
|
console.log("[RemoteHTTP] dist 目录:", resolveDistDir());
|
2026-10-30 12:12:04 +08:00
|
|
|
|
resolve({ started: true, port: _port, token: _token, url, lanIp });
|
2026-10-19 21:11:57 +08:00
|
|
|
|
});
|
|
|
|
|
|
_server.on("error", (err) => {
|
|
|
|
|
|
console.error("[RemoteHTTP] 启动失败:", err.message);
|
|
|
|
|
|
_server = null;
|
|
|
|
|
|
resolve({ started: false, error: err.message });
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stop() {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
if (!_server) return resolve({ stopped: false });
|
|
|
|
|
|
_server.close(() => {
|
|
|
|
|
|
console.log("[RemoteHTTP] 已停止");
|
|
|
|
|
|
_server = null;
|
|
|
|
|
|
resolve({ stopped: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getStatus() {
|
2026-10-30 12:12:04 +08:00
|
|
|
|
const lanIp = _server ? getLanIp() : null;
|
2026-10-19 21:11:57 +08:00
|
|
|
|
return {
|
|
|
|
|
|
running: !!_server,
|
|
|
|
|
|
port: _port,
|
|
|
|
|
|
token: _token,
|
2026-10-30 12:12:04 +08:00
|
|
|
|
lanIp,
|
|
|
|
|
|
url: lanIp && _token ? `http://${lanIp}:${_port}/?token=${_token}` : null,
|
2026-10-19 21:11:57 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IPC handler 设置:enable/disable + restart
|
|
|
|
|
|
async function configure(enabled, newPort) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 读现有配置
|
|
|
|
|
|
let cfg = { enabled, port: newPort || 8848, token: null };
|
|
|
|
|
|
const raw = _storage?.readStorage?.(STORAGE_KEY);
|
|
|
|
|
|
if (raw) {
|
|
|
|
|
|
const old = JSON.parse(raw);
|
|
|
|
|
|
cfg.token = old.token || cfg.token;
|
|
|
|
|
|
cfg.port = newPort || old.port || 8848;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 启用但 token 没生成 → 先生成 token 再启动
|
|
|
|
|
|
if (enabled && !cfg.token) cfg.token = crypto.randomBytes(12).toString("hex");
|
|
|
|
|
|
_storage?.writeStorage?.(STORAGE_KEY, JSON.stringify(cfg));
|
|
|
|
|
|
|
|
|
|
|
|
if (enabled) {
|
|
|
|
|
|
await stop();
|
|
|
|
|
|
const info = await start({ storage: _storage });
|
|
|
|
|
|
return { ok: true, ...info };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await stop();
|
|
|
|
|
|
return { ok: true, stopped: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return { ok: false, error: e.message };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = { start, stop, getStatus, configure };
|