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

@@ -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,
};
}