feat: add monitoring settings tab, workbench view, and inspection alert improvements
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

This commit is contained in:
2026-10-30 12:12:04 +08:00
committed by trae-solo-agent
parent 16dd04f746
commit 4a02370006
26 changed files with 2569 additions and 233 deletions

View File

@@ -527,6 +527,13 @@ function getRulesForDevice(readFn, deviceExt) {
});
}
// ---------- 批量保存(远程 HTTP 用) ----------
function saveRules(readFn, writeFn, rules) {
_rules = Array.isArray(rules) ? rules : [];
_save(writeFn);
return _rules;
}
// ---------- 重置(测试用) ----------
function _resetForTest() {
_rules = null;
@@ -541,5 +548,6 @@ module.exports = {
toggleRule,
evaluateRule,
getRulesForDevice,
saveRules,
_resetForTest,
};

View File

@@ -90,6 +90,17 @@ function broadcastRuleChanged() {
} catch (_) {}
}
// 相机配置广播(Settings 改了 → 所有 window 里的监控 view 收到)
function broadcastCameraConfigUpdated(cfg) {
try {
const { webContents } = require("electron");
for (const wc of webContents.getAllWebContents()) {
wc.send("inspection:camera-config-updated", cfg);
}
console.info("[inspection] broadcastCameraConfigUpdated sent");
} catch (_) {}
}
// AI 汇总状态广播(主进程主动推送 → renderer loadSnapshots 刷新)
function _broadcastReportAiStatus(snapshotId, status, error) {
try {
@@ -504,6 +515,10 @@ function registerInspectionHandlers(ipcMain, options) {
return inspectionResultStore.getRecent(readFn, limit);
});
ipcMain.handle("inspection:getResultsByTimeRange", (_e, startTime, endTime) => {
return inspectionResultStore.getByTimeRange(readFn, startTime, endTime);
});
// ---------- 单次巡检执行(前端传入完整主机配置) ----------
ipcMain.handle("inspection:runInspection", async (_e, payload) => {
const { hostId, hostConfig, catalogId } = payload || {};
@@ -1707,6 +1722,168 @@ function registerInspectionHandlers(ipcMain, options) {
}
});
// ---------- 监控大屏地图底图持久化 ----------
const _mapsDir = options.userDataDir && options.path
? options.path.join(options.userDataDir, "monitoring", "maps")
: null;
function _ensureMapsDir() {
if (!_mapsDir || !options.fs) throw new Error("maps dir not available");
if (!options.fs.existsSync(_mapsDir)) {
options.fs.mkdirSync(_mapsDir, { recursive: true });
}
}
const _ALLOWED_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
const _MAX_BG_BYTES = 20 * 1024 * 1024; // 20MB
function _sanitizeBgFilename(raw) {
if (typeof raw !== "string" || !raw.trim()) return "";
// 只保留安全字符 + 扩展名
const base = raw.replace(/[\\/]/g, "_").replace(/[^\w.\-\u4e00-\u9fff]/g, "");
return base.length > 0 ? base : "map";
}
ipcMain.handle("inspection:uploadMapBg", (_e, { filename, base64Data }) => {
try {
if (!_mapsDir) return { ok: false, error: "maps dir not available" };
if (typeof filename !== "string" || !filename) return { ok: false, error: "filename required" };
if (typeof base64Data !== "string" || !base64Data) return { ok: false, error: "base64Data required" };
// 类型校验
const ext = options.path.extname(filename).toLowerCase();
if (!_ALLOWED_EXTS.has(ext)) {
return { ok: false, error: "unsupported type, allowed: png / jpg / jpeg / webp" };
}
// 大小校验
const buf = Buffer.from(base64Data, "base64");
if (buf.length > _MAX_BG_BYTES) {
return { ok: false, error: "file too large, max 20MB" };
}
_ensureMapsDir();
const safeName = _sanitizeBgFilename(filename);
const ts = Date.now();
const storedName = `${ts}_${safeName}`;
const target = options.path.join(_mapsDir, storedName);
options.fs.writeFileSync(target, buf);
console.info("[inspection] uploadMapBg ok:", storedName, "size:", buf.length);
return { ok: true, filename: storedName, size: buf.length };
} catch (e) {
console.error("[inspection] uploadMapBg error:", e?.message);
return { ok: false, error: e?.message };
}
});
ipcMain.handle("inspection:listMapBgs", () => {
try {
if (!_mapsDir || !options.fs) return [];
if (!options.fs.existsSync(_mapsDir)) return [];
const all = options.fs.readdirSync(_mapsDir);
console.info("[inspection] listMapBgs dir:", _mapsDir, "raw entries:", all);
const entries = all.filter((f) => {
const ext = options.path.extname(f).toLowerCase();
const ok = _ALLOWED_EXTS.has(ext);
console.info("[inspection] listMapBgs filter:", f, "ext:", ext, "ok:", ok);
return ok;
});
return entries
.map((name) => {
const stat = options.fs.statSync(options.path.join(_mapsDir, name));
return { filename: name, size: stat.size, mtimeMs: stat.mtimeMs };
})
.sort((a, b) => b.mtimeMs - a.mtimeMs);
} catch (e) {
console.error("[inspection] listMapBgs error:", e?.message);
return [];
}
});
ipcMain.handle("inspection:selectMapBg", (_e, filename) => {
try {
if (typeof filename !== "string" || !filename) return { ok: false, error: "filename required" };
// 校验文件实际存在
if (_mapsDir && options.fs) {
const fullPath = options.path.join(_mapsDir, filename);
if (!options.fs.existsSync(fullPath)) {
return { ok: false, error: "file not found" };
}
}
writeStorage("_map_bg_selected", filename);
return { ok: true, filename };
} catch (e) {
return { ok: false, error: e?.message };
}
});
ipcMain.handle("inspection:getMapBgData", () => {
try {
const selected = readStorage("_map_bg_selected");
if (!selected || typeof selected !== "string") {
return { ok: false, reason: "none" };
}
if (!_mapsDir || !options.fs) return { ok: false, reason: "no-dir" };
const fullPath = options.path.join(_mapsDir, selected);
if (!options.fs.existsSync(fullPath)) {
// 选中的文件已被删除 → 清理 selected
writeStorage("_map_bg_selected", null);
return { ok: false, reason: "missing" };
}
const buf = options.fs.readFileSync(fullPath);
const ext = options.path.extname(selected).toLowerCase();
const mimeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
const mime = mimeMap[ext] || "image/png";
const dataUrl = `data:${mime};base64,${buf.toString("base64")}`;
return { ok: true, filename: selected, dataUrl, size: buf.length };
} catch (e) {
console.error("[inspection] getMapBgData error:", e?.message);
return { ok: false, error: e?.message };
}
});
ipcMain.handle("inspection:deleteMapBg", (_e, filename) => {
try {
if (!_mapsDir || !options.fs) return { ok: false, error: "maps dir not available" };
const fullPath = options.path.join(_mapsDir, filename);
if (options.fs.existsSync(fullPath)) {
options.fs.unlinkSync(fullPath);
}
// 如果删的是当前选中的 → 清空 selected
const selected = readStorage("_map_bg_selected");
if (selected === filename) {
writeStorage("_map_bg_selected", null);
}
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message };
}
});
ipcMain.handle("inspection:resetMapBg", () => {
try {
writeStorage("_map_bg_selected", null);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message };
}
});
// 相机配置:Settings window 发过来,主进程存 localStorage + 广播给所有 window
ipcMain.handle("inspection:setCameraConfig", (_e, cfg) => {
try {
if (!cfg || typeof cfg !== "object") return { ok: false, error: "cfg required" };
// 主进程侧也存一份 localStorage(和渲染进程隔离的 session)
// 但渲染进程各 window 共享同一个 session,所以写一遍即可
broadcastCameraConfigUpdated(cfg);
return { ok: true };
} catch (e) {
console.error("[inspection] setCameraConfig error:", e?.message);
return { ok: false, error: e?.message };
}
});
ipcMain.handle("inspection:getDashboardTitle", () => {
try {
return readStorage("_dashboard_title") || "";

View File

@@ -10,7 +10,7 @@
const { STORAGE_KEY_INSPECTION_RESULTS } = require("./storageKeys.cjs");
const { INSPECTION_STATUS } = require("./types.cjs");
const MAX_RESULTS = 1000;
const MAX_RESULTS = 10000;
let _results = null;

View File

@@ -509,6 +509,10 @@ function createBridgeRegistrar(context) {
writeStorage: (key, val) => inspectionStorage.writeStorage(key, val),
// 传入 sessions 用于复用终端 SSH 连接
sessions,
// 地图底图持久化:需要在主进程 userData 下写二进制图片
userDataDir,
path: require("node:path"),
fs: require("node:fs"),
});
// 给 MCP capability dispatch 里的 inspection handlers 也注入 sessions(RCA agent 用)
@@ -1194,6 +1198,63 @@ function createBridgeRegistrar(context) {
return { success: false };
}
});
// ─── 设置同步(主窗口 ↔ 远程浏览器 共享权威源) ───
// 主窗口 Electron 写设置后调这个 handler 写到 inspectionStorage,
// 这样远程浏览器 GET /api/settings 就能看到最新值
// 写完后广播给所有 renderer(主窗口实时更新)
function broadcastSettingsChanged() {
try {
for (const wc of require("electron").webContents.getAllWebContents()) {
wc.send("netcatty:settings:changed");
}
} catch {}
}
ipcMain.handle("netcatty:settings:sync", (_event, { key, value, delete: shouldDelete } = {}) => {
try {
let all = {};
const raw = inspectionStorage.readStorage("__settings__all__");
if (raw) { try { all = typeof raw === "string" ? JSON.parse(raw) : raw; } catch {} }
if (shouldDelete) {
delete all[key];
} else {
all[key] = value;
}
inspectionStorage.writeStorage("__settings__all__", JSON.stringify(all));
broadcastSettingsChanged();
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
});
ipcMain.handle("netcatty:settings:getAll", () => {
try {
const raw = inspectionStorage.readStorage("__settings__all__");
let all = {};
if (raw) { try { all = typeof raw === "string" ? JSON.parse(raw) : raw; } catch {} }
return { ok: true, settings: all };
} catch (e) {
return { ok: false, error: e?.message || String(e), settings: {} };
}
});
ipcMain.handle("netcatty:settings:syncBatch", (_event, { entries } = {}) => {
try {
let all = {};
const raw = inspectionStorage.readStorage("__settings__all__");
if (raw) { try { all = typeof raw === "string" ? JSON.parse(raw) : raw; } catch {} }
if (entries && typeof entries === "object") {
for (const [k, v] of Object.entries(entries)) {
if (v === null || v === undefined) delete all[k];
else all[k] = v;
}
}
inspectionStorage.writeStorage("__settings__all__", JSON.stringify(all));
broadcastSettingsChanged();
return { ok: true, count: entries ? Object.keys(entries).length : 0 };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
});
console.log('[Main] All bridges registered successfully');
@@ -1205,7 +1266,8 @@ function createBridgeRegistrar(context) {
ipcMain.handle("remoteHttp:getStatus", () => remoteHttp.getStatus());
ipcMain.handle("remoteHttp:configure", (_e, enabled, port) => remoteHttp.configure(enabled, port));
// 自动启动(打包版才有意义,开发版也启方便调试)
remoteHttp.start({ storage: inspectionStorage }).catch((e) => {
// storage: inspectionStorage 共享权威源,onSettingsChanged 远程写完后广播给主窗口
remoteHttp.start({ storage: inspectionStorage, onSettingsChanged: broadcastSettingsChanged }).catch((e) => {
console.warn("[RemoteHTTP] auto-start failed:", e.message);
});
} catch (e) {

View File

@@ -947,6 +947,66 @@ const allowedOrigins = getAllowedRendererOrigins();
if (isTrustedRendererLocation(allowedOrigins)) {
contextBridge.exposeInMainWorld("netcatty", {
...existing, ...api, ...figSpecApi,
// 设置同步:renderer localStorage 变化通知主进程权威源
settingsSync: (key, value) => ipcRenderer.invoke("netcatty:settings:sync", {
key,
value,
delete: value === null,
}),
settingsSyncBatch: (entries) => ipcRenderer.invoke("netcatty:settings:syncBatch", { entries }),
settingsGetAll: () => ipcRenderer.invoke("netcatty:settings:getAll"),
// 远程浏览器改了设置 → 主窗口实时拉取更新 localStorage
onSettingsChanged: (callback) => {
const handler = () => {
ipcRenderer.invoke("netcatty:settings:getAll").then((r) => {
if (r?.ok && r.settings && typeof r.settings === "object") {
// 逐键对比更新(不暴力清空,避免触发过度重渲染)
try {
const changedKeys = [];
const changedEntries = {};
const existingKeys = new Set(Object.keys(localStorage));
for (const [k, v] of Object.entries(r.settings)) {
if (v === null || v === undefined) {
if (localStorage.getItem(k) !== null) {
localStorage.removeItem(k);
changedKeys.push(k);
changedEntries[k] = null;
}
} else {
const strVal = String(v);
if (localStorage.getItem(k) !== strVal) {
localStorage.setItem(k, strVal);
changedKeys.push(k);
changedEntries[k] = strVal;
}
}
existingKeys.delete(k);
}
// 权威源里没有但本地有的键——保留(可能是 Electron 专有设置)
// 只有实际有变化时才触发事件
if (changedKeys.length > 0) {
// 触发同窗口 CustomEvent 让 React hooks 感知(携带完整 settings 和变化的 key 列表)
try {
window.dispatchEvent(new CustomEvent("netcatty-settings-batch", {
detail: { changedKeys, changedEntries, settings: r.settings },
}));
} catch {}
try { window.dispatchEvent(new Event("monitoring-camera-changed")); } catch {}
// 向后兼容:触发原生 storage 事件(不带 key 信息,同窗口内依赖它的 hook 至少能收到通知)
try { window.dispatchEvent(new Event("storage")); } catch {}
// 回调同时传递完整 settings 字典和逐 key 变化信息
try { callback({ settings: r.settings, changedKeys, changedEntries }); } catch {}
} else {
// 即使没有变化也通知(权威源同步确认)
try { callback({ settings: r.settings, changedKeys: [], changedEntries: {} }); } catch {}
}
} catch {}
}
}).catch(() => {});
};
ipcRenderer.on("netcatty:settings:changed", handler);
return () => ipcRenderer.removeListener("netcatty:settings:changed", handler);
},
// portable 版标志:renderer 据此跳过 LS hosts_v1 加密读取,
// 直接走 inspection-data.json 明文(DPAPI 绑定 userData 路径解不开 dev 加密 blob)。
isPortable: process.env.NETCATTY_PORTABLE === "1",

View File

@@ -2094,6 +2094,7 @@ function createPreloadApi(ctx) {
getLatestAll: () => ipcRenderer.invoke("inspection:getLatestAll"),
getLatestByDevice: (hostId, limit) => ipcRenderer.invoke("inspection:getLatestByDevice", hostId, limit),
getRecentResults: (limit) => ipcRenderer.invoke("inspection:getRecentResults", limit),
getResultsByTimeRange: (startTime, endTime) => ipcRenderer.invoke("inspection:getResultsByTimeRange", startTime, endTime),
listAlerts: (options) => ipcRenderer.invoke("inspection:listAlerts", options),
getActiveAlerts: () => ipcRenderer.invoke("inspection:getActiveAlerts"),
getAlertStats: () => ipcRenderer.invoke("inspection:getAlertStats"),
@@ -2140,6 +2141,21 @@ function createPreloadApi(ctx) {
getVaultData: (key) => ipcRenderer.invoke("inspection:getVault:" + key),
saveTopologyLinks: (links) => ipcRenderer.invoke("inspection:saveTopologyLinks", links),
getTopologyLinks: () => ipcRenderer.invoke("inspection:getTopologyLinks"),
// 监控大屏地图底图(用户上传 userData/monitoring/maps,Three.js 渲染消费)
uploadMapBg: (filename, base64Data) => ipcRenderer.invoke("inspection:uploadMapBg", { filename, base64Data }),
listMapBgs: () => ipcRenderer.invoke("inspection:listMapBgs"),
selectMapBg: (filename) => ipcRenderer.invoke("inspection:selectMapBg", filename),
getMapBgData: () => ipcRenderer.invoke("inspection:getMapBgData"),
deleteMapBg: (filename) => ipcRenderer.invoke("inspection:deleteMapBg", filename),
resetMapBg: () => ipcRenderer.invoke("inspection:resetMapBg"),
// 相机配置:Settings window 调这个 → 主进程广播给所有 window
setCameraConfig: (cfg) => ipcRenderer.invoke("inspection:setCameraConfig", cfg),
// 监听主进程推过来的相机配置变化
onCameraConfigUpdated: (callback) => {
const handler = (_event, cfg) => callback(cfg);
ipcRenderer.on("inspection:camera-config-updated", handler);
return () => ipcRenderer.removeListener("inspection:camera-config-updated", handler);
},
// 巡检报告调度配置
getReportSchedule: () => ipcRenderer.invoke("inspection:getReportSchedule"),
saveReportSchedule: (config) => ipcRenderer.invoke("inspection:saveReportSchedule", config),

View File

@@ -15,12 +15,63 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
// ─── 获取本机局域网 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";
}
}
let _server = null;
let _port = 8848;
let _token = null;
let _storage = null; // { readStorage, writeStorage }
let _onSettingsChanged = null; // 远程写完设置后通知主窗口
let _distDir = null; // 运行时解析的 dist 绝对路径
const STORAGE_KEY = "netcatty_remote_access";
@@ -147,7 +198,8 @@ function loadModules() {
const alertRuleStore = require("./bridges/inspection/alertRuleStore.cjs");
const reportStore = require("./bridges/inspection/reportStore.cjs");
const dailyReportStore = require("./bridges/inspection/dailyReportStore.cjs");
return { inspectionStorage, deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore, reportStore, dailyReportStore };
const reportScheduleStore = require("./bridges/inspection/reportScheduleStore.cjs");
return { inspectionStorage, deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore, reportStore, dailyReportStore, reportScheduleStore };
}
// 每次 API 请求前重置所有 store 的模块级缓存,强制从磁盘重新加载
@@ -255,6 +307,62 @@ function readStorage(key) {
// ─── 路由 ────
async function handleApi(pathname, method, body, query) {
// /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) };
}
}
const modules = loadModules();
// 每次请求前重置所有 store 缓存,确保读到磁盘最新数据
resetAllCaches(modules);
@@ -349,18 +457,56 @@ async function handleApi(pathname, method, body, query) {
return { ok: true, rules };
}
// 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) };
}
}
// GET /api/inspection/results —— 所有设备最新巡检结果(完整结构,monitoring 页面用)
if (method === "GET" && pathname === "/api/inspection/results") {
const all = inspectionResultStore.getLatestAll(readStorage);
return { ok: true, results: Object.values(all) };
}
// GET /api/inspection/results/recent —— 最近巡检结果
// GET /api/inspection/results/recent —— 最近巡检结果(支持 query.limit,默认 500 覆盖 7 天历史)
if (method === "GET" && pathname === "/api/inspection/results/recent") {
const all = inspectionResultStore.getRecent(readStorage, 50);
const limit = Math.min(parseInt(query.limit, 10) || 500, 5000);
const all = inspectionResultStore.getRecent(readStorage, limit);
return { ok: true, results: all };
}
// 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 };
}
// GET /api/devices/enabled —— 已启用巡检的设备列表
if (method === "GET" && pathname === "/api/devices/enabled") {
const enabled = deviceStore.getEnabledDevices(readStorage);
@@ -613,9 +759,10 @@ async function handleApi(pathname, method, body, query) {
}
// GET /api/vault/:key —— Vault 子页面通用只读路由
// 支持 keys/proxies/port-forwards/snippets/notes/known-hosts/connection-logs/groups/identities
// 敏感数据(keys/identities)自动脱敏
// 支持 hosts/keys/proxies/port-forwards/snippets/notes/known-hosts/connection-logs/groups/identities
// 敏感数据(keys/identities/hosts)自动脱敏
const _VAULT_ROUTE_MAP = {
hosts: "_vault_hosts",
keys: "_vault_keys",
proxies: "_vault_proxies",
"port-forwards": "_vault_port_forwards",
@@ -652,8 +799,8 @@ async function handleApi(pathname, method, body, query) {
if (typeof raw === "string") {
try { data = JSON.parse(raw); } catch { data = raw; }
}
// keys 和 identities 需要脱敏
if (key === "keys" || key === "identities") {
// keys、identities 和 hosts 需要脱敏(hosts 里可能带 password)
if (key === "keys" || key === "identities" || key === "hosts") {
data = _stripSensitive(data);
}
return { ok: true, data: data ?? (Array.isArray(data) ? [] : {}) };
@@ -662,6 +809,117 @@ async function handleApi(pathname, method, body, query) {
}
}
// 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) };
}
}
// GET /api/report-preview —— 远程浏览器动态生成 PDF 并返回流
// query: mode=snapshot|daily&id=<snapshotId|reportId>
if (method === "GET" && pathname === "/api/report-preview") {
@@ -694,11 +952,17 @@ async function handleApi(pathname, method, body, query) {
filePath = await reportExport.exportSnapshotPdf(app, snap, snap.ai_summary || undefined, null, systemName);
} else if (mode === "daily") {
dailyReportStore._resetForTest?.();
const report = dailyReportStore.getReport(readStorage, id);
const allReports = dailyReportStore.listDailyReports(readStorage, 500);
const report = (allReports || []).find((r) => r?.id === id);
if (!report) return { ok: false, error: "日报告不存在" };
reportStore._resetForTest?.();
const allSnapshots = reportStore.listSnapshots(readStorage);
filePath = await reportExport.exportDailyReportPdf(app, report, null, allSnapshots, systemName);
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);
} else {
return { ok: false, error: "未知 mode" };
}
@@ -887,6 +1151,7 @@ async function handleApi(pathname, method, body, query) {
// ─── 主入口 ────
async function start(options) {
_storage = options?.storage;
_onSettingsChanged = options?.onSettingsChanged;
// 读配置
let enabled = false;
@@ -1000,12 +1265,12 @@ async function start(options) {
return;
}
// 读 dist/index.html 并在 </head> 前注入 token(唯一一处,避免破坏其他标签)
// 读 dist/index.html 并在 </head> 前注入 token + 同步设置预加载
// 同步 XHR 确保 React SPA 启动前 localStorage 已有权威设置值
try {
const htmlPath = path.join(resolveDistDir(), "index.html");
let html = fs.readFileSync(htmlPath, "utf-8");
const inject = `<script>window.__REMOTE_TOKEN__=${JSON.stringify(_token)};window.__REMOTE_BASE__='/';</script>`;
// 只注入一处:</head> 前最稳妥(Vite 模板里 </head> 只有一处)
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>`;
html = html.replace("</head>", inject + "</head>");
sendHtml(res, 200, html);
} catch (e) {
@@ -1017,12 +1282,14 @@ async function start(options) {
return new Promise((resolve) => {
_server.listen(_port, "0.0.0.0", () => {
const url = `http://0.0.0.0:${_port}/?token=${_token}`;
const lanIp = getLanIp();
const url = `http://${lanIp}:${_port}/?token=${_token}`;
console.log("[RemoteHTTP] ✅ 远程 HTTP 服务已启动");
console.log("[RemoteHTTP] 监听端口:", _port);
console.log("[RemoteHTTP] 访问 URL:", url);
console.log("[RemoteHTTP] 本机 URL (localhost):", `http://localhost:${_port}/?token=${_token}`);
console.log("[RemoteHTTP] 局域网 URL:", url);
console.log("[RemoteHTTP] dist 目录:", resolveDistDir());
resolve({ started: true, port: _port, token: _token, url });
resolve({ started: true, port: _port, token: _token, url, lanIp });
});
_server.on("error", (err) => {
console.error("[RemoteHTTP] 启动失败:", err.message);
@@ -1044,10 +1311,13 @@ function stop() {
}
function getStatus() {
const lanIp = _server ? getLanIp() : null;
return {
running: !!_server,
port: _port,
token: _token,
lanIp,
url: lanIp && _token ? `http://${lanIp}:${_port}/?token=${_token}` : null,
};
}