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