From 2e71e03d71e4c4b29bab398addf9a26ab3a1e0fe Mon Sep 17 00:00:00 2001
From: zhaolei <256168157@qq.com>
Date: Mon, 19 Oct 2026 21:11:57 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A1=E6=A3=80=E5=A2=9E=E5=BC=BA=20?=
=?UTF-8?q?/=20=E6=8B=93=E6=89=91=E5=A4=A7=E5=B1=8F=20/=20=E8=BF=9C?=
=?UTF-8?q?=E7=A8=8B=20HTTP=20/=20portable=20=E7=BB=BF=E8=89=B2=E6=89=93?=
=?UTF-8?q?=E5=8C=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立
Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心
与告警中心页增强。
拓扑大屏:MonitoringTopologyView 扩展分组/布局控制,
useMonitoringData 重构数据流。
远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露
IPC,registerBridges 注册 netcatty:http:open 桥。
portable 绿色打包:
- electron-builder.config.cjs 恢复原始白名单(61MB app.asar),
收集器自动追踪运行时依赖;
- portableData.cjs 重写为 .portable 标记检测;
- scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage
的 http://localhost:5173 origin 批量迁移为 app://netcatty;
- scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
---
application/app/AppSideEffects.tsx | 18 +
application/state/useAppLockRuntime.ts | 38 +-
application/state/useReportAutoScheduler.ts | 6 +
application/state/useSettingsState.ts | 64 +
application/state/useVaultState.ts | 142 ++-
components/SettingsApplicationTab.tsx | 161 ++-
components/inspection/AlertCenterPage.tsx | 50 +
.../inspection/InspectionCenterPage.tsx | 99 +-
components/inspection/ReportPage.tsx | 15 +-
.../monitoring/MonitoringTopologyView.tsx | 171 ++-
components/monitoring/useMonitoringData.ts | 93 +-
components/vault/VaultViewLayout.tsx | 2 +
electron-builder.config.cjs | 132 +-
electron/bridges/aiBridge.cjs | 38 +-
.../bridges/aiBridge/providerHandlers.cjs | 5 +
.../bridges/inspection/alertRecordStore.cjs | 6 +-
.../bridges/inspection/alertRuleStore.cjs | 6 +-
.../bridges/inspection/dailyReportStore.cjs | 7 +-
electron/bridges/inspection/deviceStore.cjs | 3 +
electron/bridges/inspection/index.cjs | 693 ++++++++++-
.../inspection/inspectionResultStore.cjs | 6 +-
.../inspection/inspectionScheduler.cjs | 24 +-
.../bridges/inspection/inspectionStorage.cjs | 8 +
.../inspection/reportScheduleStore.cjs | 4 +-
.../bridges/inspection/reportScheduler.cjs | 3 +-
electron/bridges/inspection/reportStore.cjs | 9 +-
electron/main.cjs | 3 +-
electron/main/registerBridges.cjs | 15 +
electron/portableData.cjs | 31 +-
electron/preload/api.cjs | 26 +
electron/remoteHttpServer.cjs | 1082 +++++++++++++++++
infrastructure/inspection/inspectionApi.ts | 135 +-
scripts/migratePortableLocalStorage.cjs | 99 ++
scripts/scanWorkspaceDeps.cjs | 48 +
34 files changed, 3012 insertions(+), 230 deletions(-)
create mode 100644 electron/remoteHttpServer.cjs
create mode 100644 scripts/migratePortableLocalStorage.cjs
create mode 100644 scripts/scanWorkspaceDeps.cjs
diff --git a/application/app/AppSideEffects.tsx b/application/app/AppSideEffects.tsx
index 98efe8b..2e035d0 100644
--- a/application/app/AppSideEffects.tsx
+++ b/application/app/AppSideEffects.tsx
@@ -108,6 +108,24 @@ export function AppSideEffects() {
const { t } = useI18n();
const pluginViewTabs = usePluginViewTabs();
+ // 启动时自动 sync AI providers 到主进程(主进程会持久化到 inspectionStorage)
+ // 解决远程浏览器 / 主进程重启后 providerConfigs 为空的问题
+ useEffect(() => {
+ const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ if (isRemote) return; // 远程浏览器没有 preload bridge
+ const nc = (window as any).netcatty;
+ if (!nc?.aiSyncProviders) return;
+ try {
+ const raw = localStorage.getItem("netcatty_ai_providers_v1");
+ if (raw) {
+ const providers = JSON.parse(raw);
+ if (Array.isArray(providers) && providers.length > 0) {
+ nc.aiSyncProviders(providers).catch(() => {});
+ }
+ }
+ } catch {}
+ }, []);
+
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
// Combined state for the AddToWorkspaceDialog. null = closed; mode
diff --git a/application/state/useAppLockRuntime.ts b/application/state/useAppLockRuntime.ts
index 40ea760..181cf5c 100644
--- a/application/state/useAppLockRuntime.ts
+++ b/application/state/useAppLockRuntime.ts
@@ -52,22 +52,44 @@ export function useAppLockRuntime(
'getAppLockRuntimeState' | 'onAppLockRuntimeStateChanged'
> | null | undefined,
) {
- const [runtimeState, setRuntimeState] = useState(DEFAULT_RUNTIME_APP_LOCK_STATE);
+ // 浏览器远程访问环境下(无 Electron bridge),直接 unlocked
+ const BROWSER_ONLY = typeof window !== 'undefined' && !!(window as any).__REMOTE_TOKEN__;
+ const [runtimeState, setRuntimeState] = useState(
+ BROWSER_ONLY
+ ? { initialized: true, locked: false, reason: null, version: 0, lastLockedAt: null, lastUnlockedAt: null, lastActivityAt: null }
+ : DEFAULT_RUNTIME_APP_LOCK_STATE,
+ );
const refreshRuntimeState = useCallback(async () => {
+ // 浏览器远程访问:直接返回 unlocked
+ if (BROWSER_ONLY) {
+ const unlocked: RuntimeAppLockState = {
+ initialized: true,
+ locked: false,
+ reason: null,
+ version: 0,
+ lastLockedAt: null,
+ lastUnlockedAt: null,
+ lastActivityAt: null,
+ };
+ setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, unlocked));
+ return unlocked;
+ }
const nextState = await bridge?.getAppLockRuntimeState?.();
const normalized = normalizeRuntimeAppLockState(nextState);
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
return normalized;
- }, [bridge]);
+ }, [bridge, BROWSER_ONLY]);
useEffect(() => {
let cancelled = false;
- const unsubscribe = bridge?.onAppLockRuntimeStateChanged?.((nextState) => {
- if (cancelled) return;
- const normalized = normalizeRuntimeAppLockState(nextState);
- setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
- }) ?? (() => {});
+ const unsubscribe = BROWSER_ONLY
+ ? () => {}
+ : (bridge?.onAppLockRuntimeStateChanged?.((nextState) => {
+ if (cancelled) return;
+ const normalized = normalizeRuntimeAppLockState(nextState);
+ setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
+ }) ?? (() => {}));
void refreshRuntimeState().catch(() => {
if (cancelled) return;
@@ -81,7 +103,7 @@ export function useAppLockRuntime(
cancelled = true;
unsubscribe();
};
- }, [bridge, refreshRuntimeState]);
+ }, [bridge, refreshRuntimeState, BROWSER_ONLY]);
return {
runtimeState,
diff --git a/application/state/useReportAutoScheduler.ts b/application/state/useReportAutoScheduler.ts
index d6e4431..2787e46 100644
--- a/application/state/useReportAutoScheduler.ts
+++ b/application/state/useReportAutoScheduler.ts
@@ -146,6 +146,12 @@ export function useReportAutoScheduler() {
/** mount 后延迟同步一次 hostConfigs 给主进程,让主进程 reportScheduler 能独立执行定时快照 */
useEffect(() => {
+ // 远程浏览器环境:vault localStorage 加密数据读不到,无法解密 hosts,跳过定时巡检报告逻辑
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ if (IS_REMOTE) {
+ console.info("[ReportAutoScheduler] 远程访问环境,跳过巡检报告自动调度");
+ return;
+ }
const syncOnce = async () => {
if (hostsSyncedRef.current) return;
try {
diff --git a/application/state/useSettingsState.ts b/application/state/useSettingsState.ts
index 7a8049e..fac3bac 100644
--- a/application/state/useSettingsState.ts
+++ b/application/state/useSettingsState.ts
@@ -85,6 +85,8 @@ import {
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_PRODUCT_NAME,
+ STORAGE_KEY_MONITORING_DASHBOARD_TITLE,
+ STORAGE_KEY_REPORT_PRODUCT_NAME,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -273,10 +275,67 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
});
+
// 产品名称配置(用于标题栏、侧边栏 Logo 等)
const [productName, setProductName] = useState(() => {
return localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || '云启智能运维平台';
});
+
+ // 远程浏览器:启动时从 /api/dashboard-title 拉取产品名系列配置,写入 localStorage
+ useEffect(() => {
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ if (!IS_REMOTE) return;
+ const token = (window as any).__REMOTE_TOKEN__ || "";
+ fetch(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
+ .then(r => r.json())
+ .then(d => {
+ let changed = false;
+ if (d?.productName) {
+ localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, d.productName);
+ setProductName(d.productName);
+ changed = true;
+ }
+ if (d?.dashboardTitle) {
+ localStorageAdapter.writeString(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, d.dashboardTitle);
+ changed = true;
+ }
+ if (d?.reportProductName !== undefined) {
+ localStorageAdapter.writeString(STORAGE_KEY_REPORT_PRODUCT_NAME, d.reportProductName);
+ changed = true;
+ }
+ if (changed) {
+ try { window.dispatchEvent(new CustomEvent('productNameChanged')); } catch {}
+ try { window.dispatchEvent(new CustomEvent('monitoringTitleChanged')); } catch {}
+ }
+ })
+ .catch(() => {});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Electron 本地:启动时从主进程读取产品名系列配置 → 恢复到 Local Storage
+ // 解决绿色便携版首次启动 Local Storage 空时显示旧默认值的问题
+ useEffect(() => {
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ if (IS_REMOTE) return; // 远程浏览器有上面的 effect 处理
+ const nc = (window as any).netcatty;
+ if (!nc?.inspection) return;
+
+ const localProductName = localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME);
+ const needsRestore = !localProductName; // Local Storage 空才需要恢复
+
+ if (needsRestore) {
+ nc.inspection.getProductName?.().then((v: string) => {
+ if (v && v.length > 0) {
+ localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, v);
+ setProductName(v);
+ console.info('[useSettingsState] restored productName from main:', v);
+ }
+ }).catch(() => {});
+ }
+ // dashboardTitle 和 reportProductName 在 SettingsApplicationTab 里单独恢复
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const [syncConfig, setSyncConfig] = useState(() => localStorageAdapter.read(STORAGE_KEY_SYNC));
const [terminalThemeId, setTerminalThemeId] = useState(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState(() => {
@@ -1171,6 +1230,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Persist product name
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, productName);
+ // 同步到主进程 inspection-data.json,远程浏览器 API 可读
+ try {
+ const nc = (window as any).netcatty;
+ nc?.inspection?.saveProductName?.(productName).catch(() => {});
+ } catch {}
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_PRODUCT_NAME, productName);
// 更新窗口标题栏
diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts
index be37d46..924867c 100644
--- a/application/state/useVaultState.ts
+++ b/application/state/useVaultState.ts
@@ -1364,6 +1364,80 @@ export const useVaultState = () => {
let cancelled = false;
const init = async () => {
try {
+ // ─── 远程浏览器环境:从 HTTP API 获取所有 Vault 数据,跳过 Electron localStorage 加密路径
+ const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ if (isRemote) {
+ try {
+ const token = (window as any).__REMOTE_TOKEN__ || "";
+ const base = (p: string) => `${p}?token=${encodeURIComponent(token)}`;
+ const apiBase = `/api/vault`;
+
+ // 并行 fetch 所有 vault 数据
+ const results = await Promise.allSettled([
+ fetch(base("/api/devices")).then(r => r.json()),
+ fetch(base(`${apiBase}/keys`)).then(r => r.json()),
+ fetch(base(`${apiBase}/proxies`)).then(r => r.json()),
+ fetch(base(`${apiBase}/port-forwards`)).then(r => r.json()),
+ fetch(base(`${apiBase}/snippets`)).then(r => r.json()),
+ fetch(base(`${apiBase}/notes`)).then(r => r.json()),
+ fetch(base(`${apiBase}/known-hosts`)).then(r => r.json()),
+ fetch(base(`${apiBase}/connection-logs`)).then(r => r.json()),
+ fetch(base(`${apiBase}/groups`)).then(r => r.json()),
+ fetch(base(`${apiBase}/identities`)).then(r => r.json()),
+ ]);
+ const getOk = (idx: number, fallback: any = null) =>
+ results[idx]?.status === "fulfilled" && (results[idx]?.value as any)?.ok
+ ? (results[idx]?.value as any).data ?? (results[idx]?.value as any).devices ?? fallback
+ : fallback;
+
+ // hosts
+ const devicesData = getOk(0, []);
+ if (Array.isArray(devicesData)) {
+ const remoteHosts: Host[] = devicesData.map((d: any) => {
+ const catalogId = d.catalog_id || d.type || "";
+ return {
+ id: d.host_id || crypto.randomUUID(),
+ label: d.label || d.hostname || d.host_id,
+ hostname: d.hostname || "",
+ port: 22, username: "", tags: [], os: "linux",
+ deviceType: /switch|firewall|router/i.test(catalogId) ? "network" : "general",
+ distro: catalogId, distroMode: "auto",
+ } as any;
+ });
+ hostsRef.current = remoteHosts;
+ setHosts(remoteHosts);
+ }
+ // keys(已被 remoteHttpServer strip 敏感字段)
+ const keysData = getOk(1, []);
+ if (Array.isArray(keysData)) setKeys(keysData);
+ // proxies
+ const proxiesData = getOk(2, []);
+ if (Array.isArray(proxiesData)) setProxyProfiles(proxiesData);
+ // snippets
+ const snData = getOk(4, []);
+ if (Array.isArray(snData)) setSnippets(snData);
+ // notes(可能是数组或对象)
+ const notesData = getOk(5, null);
+ if (notesData) setNotes(Array.isArray(notesData) ? notesData : (notesData.notes || []));
+ // known hosts
+ const khData = getOk(6, []);
+ if (Array.isArray(khData)) setKnownHosts(khData);
+ // connection logs
+ const logData = getOk(7, []);
+ if (Array.isArray(logData)) setConnectionLogs(logData);
+ // identities
+ const idData = getOk(9, []);
+ if (Array.isArray(idData)) setIdentities(idData);
+ } catch (e) {
+ console.warn("[useVaultState] 远程 vault fetch 失败:", e);
+ }
+ if (!cancelled) {
+ setIsInitialized(true);
+ setVaultInitialized(true);
+ }
+ return; // 远程环境到此为止,跳过所有 Electron IPC 加载
+ }
+
await withVaultImportLock("vault", async () => {
recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS);
});
@@ -1401,7 +1475,56 @@ export const useVaultState = () => {
hostsWritePendingRef.current = writePromise;
}
} else {
- updateHosts(INITIAL_HOSTS);
+ // 本地 Local Storage 为空(可能是 origin 隔离导致的打包版环境)
+ // 尝试从主进程 inspectionStorage 的 _vault_hosts 恢复
+ let restored = false;
+ const nc = (window as any).netcatty;
+ if (nc?.inspection?.getVaultHosts) {
+ try {
+ const vaultHosts = await nc.inspection.getVaultHosts();
+ if (Array.isArray(vaultHosts) && vaultHosts.length > 0) {
+ // 主进程 inspectionStorage 现在已存完整字段(含凭证),直接复制
+ const migrated: Host[] = vaultHosts.map((v: any) => ({
+ id: v.id,
+ label: v.label || "",
+ hostname: v.hostname || "",
+ port: v.port || 22,
+ username: v.username || "",
+ password: v.password || undefined,
+ identityFileId: v.identityFileId || undefined,
+ privateKey: v.privateKey || "",
+ passphrase: v.passphrase || "",
+ tags: v.tags || [],
+ os: v.os || "linux",
+ type: v.type || "",
+ distro: v.distro || "",
+ group: v.group || "",
+ }));
+ if (cancelled) return;
+ const sanitized = normalizeVaultOrder(
+ migrated.map((h) => sanitizeHost(h)),
+ );
+ hostsRef.current = sanitized;
+ setHosts(sanitized);
+ // 加密写回 localStorage,后续启动就正常了
+ const ver = ++hostsWriteVersion.current;
+ const encryptPromise = encryptHosts(sanitized);
+ hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
+ const writePromise = encryptPromise.then(async (enc) => {
+ if (ver !== hostsWriteVersion.current) return;
+ return commitEncryptedHostsUnderVaultLock(ver, sanitized, enc);
+ });
+ hostsWritePendingRef.current = writePromise;
+ restored = true;
+ console.info("[useVaultState] 从 inspectionStorage 恢复 hosts:", sanitized.length, "台");
+ }
+ } catch (e) {
+ console.warn("[useVaultState] 从 inspectionStorage 恢复失败:", e);
+ }
+ }
+ if (!restored) {
+ updateHosts(INITIAL_HOSTS);
+ }
}
// Read keys fresh here (not before the hosts await) so we don't apply
@@ -2133,6 +2256,23 @@ export const useVaultState = () => {
[importData],
);
+ // ─── Electron 本地:把所有 vault 数据同步到 inspection-data.json(远程 HTTP API 读)
+ // 仅在 Electron 本地生效(远程浏览器不走 sync,直接 fetch)
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ if ((window as any).__REMOTE_TOKEN__) return; // 远程浏览器不走
+ const nc = (window as any).netcatty;
+ if (!nc?.inspection?.syncVaultData) return;
+ try { nc.inspection.syncVaultData("hosts", hosts).catch(() => {}); } catch {}
+ try { nc.inspection.syncVaultData("keys", keys).catch(() => {}); } catch {}
+ try { nc.inspection.syncVaultData("identities", identities).catch(() => {}); } catch {}
+ try { nc.inspection.syncVaultData("proxies", proxyProfiles).catch(() => {}); } catch {}
+ try { nc.inspection.syncVaultData("snippets", snippets).catch(() => {}); } catch {}
+ try { nc.inspection.syncVaultData("notes", notes).catch(() => {}); } catch {}
+ try { nc.inspection.syncVaultData("known_hosts", knownHosts).catch(() => {}); } catch {}
+ try { nc.inspection.syncVaultData("connection_logs", connectionLogs).catch(() => {}); } catch {}
+ }, [hosts, keys, identities, proxyProfiles, snippets, notes, knownHosts, connectionLogs]);
+
return {
isInitialized,
hosts,
diff --git a/components/SettingsApplicationTab.tsx b/components/SettingsApplicationTab.tsx
index f26a0bb..925a9f4 100644
--- a/components/SettingsApplicationTab.tsx
+++ b/components/SettingsApplicationTab.tsx
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
-import { ArrowUpCircle, Check, Loader2, RefreshCcw } from "lucide-react";
+import { ArrowUpCircle, Check, Copy, Globe, Loader2, RefreshCcw, Server } from "lucide-react";
import AppLogo from "./AppLogo";
import AppWordmark from "./AppWordmark";
import { Button } from "./ui/button";
+import { Switch } from "./ui/switch";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
@@ -10,6 +11,7 @@ import { SettingsAnchor, SettingsTabContent } from "./settings/settings-ui";
import { toast } from "./ui/toast";
import { useStoredString } from "../application/state/useStoredString";
import { STORAGE_KEY_MONITORING_DASHBOARD_TITLE, STORAGE_KEY_REPORT_PRODUCT_NAME } from "../infrastructure/config/storageKeys";
+import { cn } from "../lib/utils";
type AppInfo = {
name: string;
@@ -36,6 +38,66 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
const [monitoringTitle, setMonitoringTitle] = useStoredString(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, '云启Net智能巡检监测系统');
const [reportProductName, setReportProductName] = useStoredString(STORAGE_KEY_REPORT_PRODUCT_NAME, '');
+ // Electron 本地:启动时从主进程恢复 dashboardTitle / reportProductName
+ // 解决绿色便携版首次启动 Local Storage 空时显示旧默认值的问题
+ useEffect(() => {
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ if (IS_REMOTE) return;
+ const nc = (window as any).netcatty;
+ if (!nc?.inspection) return;
+
+ // 恢复大屏标题(如果 Local Storage 空或用的是默认值)
+ nc.inspection.getDashboardTitle?.().then((v: string) => {
+ if (v && v.length > 0 && v !== '云启Net智能巡检监测系统') {
+ const local = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
+ if (!local || local === '云启Net智能巡检监测系统') {
+ setMonitoringTitle(v);
+ console.info('[Settings] restored monitoringTitle:', v);
+ }
+ }
+ }).catch(() => {});
+
+ // 恢复报告产品名
+ nc.inspection.getReportProductName?.().then((v: string) => {
+ if (v && v.length > 0) {
+ const local = localStorage.getItem(STORAGE_KEY_REPORT_PRODUCT_NAME);
+ if (!local || local === '') {
+ setReportProductName(v);
+ console.info('[Settings] restored reportProductName:', v);
+ }
+ }
+ }).catch(() => {});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // 同步到主进程 inspection-data.json(远程浏览器 API 可读)
+ useEffect(() => {
+ try {
+ const nc = (window as any).netcatty;
+ if (monitoringTitle) nc?.inspection?.saveDashboardTitle?.(monitoringTitle).catch(() => {});
+ nc?.inspection?.saveReportProductName?.(reportProductName || '').catch(() => {});
+ } catch {}
+ }, [monitoringTitle, reportProductName]);
+
+ // 远程 HTTP Server 状态
+ const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null }>({
+ running: false, port: 8848, token: null,
+ });
+ const [remoteLoading, setRemoteLoading] = useState(false);
+ const [remotePortDraft, setRemotePortDraft] = useState("8848");
+
+ const loadRemoteStatus = async () => {
+ try {
+ const s = await (window as any).netcatty?.remoteHttp?.getStatus?.();
+ if (s) {
+ setRemoteStatus({ running: !!s.running, port: s.port || 8848, token: s.token || null });
+ setRemotePortDraft(String(s.port || 8848));
+ }
+ } catch {}
+ };
+
+ useEffect(() => { void loadRemoteStatus(); }, []);
+
useEffect(() => {
let cancelled = false;
const load = async () => {
@@ -99,6 +161,44 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
setTimeout(() => setLastCheckResult('none'), 3000);
};
+ // ─── 远程访问 handlers ───
+ const handleToggleRemote = async (checked: boolean) => {
+ setRemoteLoading(true);
+ try {
+ const port = parseInt(remotePortDraft, 10) || 8848;
+ const res = await (window as any).netcatty?.remoteHttp?.configure?.(checked, port);
+ if (res?.ok) {
+ setRemoteStatus({
+ running: !!checked,
+ port: res.port || port,
+ token: res.token || remoteStatus.token,
+ });
+ setRemotePortDraft(String(res.port || port));
+ if (checked) toast.success("远程访问已启动");
+ else toast.success("远程访问已停止");
+ } else {
+ toast.error(res?.error || "操作失败");
+ await loadRemoteStatus();
+ }
+ } catch (e: any) {
+ toast.error(e?.message || "操作失败");
+ await loadRemoteStatus();
+ } finally {
+ setRemoteLoading(false);
+ }
+ };
+
+ const handleCopyUrl = async () => {
+ if (!remoteStatus.token) { toast.error("请先启用远程访问"); return; }
+ const url = `http://localhost:${remoteStatus.port}/dashboard?token=${remoteStatus.token}`;
+ try {
+ await navigator.clipboard.writeText(url);
+ toast.success("访问链接已复制");
+ } catch {
+ toast.error("复制失败");
+ }
+ };
+
return (
@@ -168,6 +268,65 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
onCommit={(v) => setReportProductName(v)}
/>
+
+ {/* 远程访问(浏览器 HTTP Server) */}
+
+
+
+
+
+
+ {remoteStatus.running ? (
+
+
+ 运行中
+
+ ) : (
+
+ 已停止
+
+ )}
+
+
+
+
+
+
+
+ setRemotePortDraft(e.target.value)}
+ className="w-24 px-2 py-1 rounded-md border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+ (默认 8848,修改后需重启远程访问生效)
+
+
+ {remoteStatus.running && remoteStatus.token && (
+
+
+
+ http://localhost:{remoteStatus.port}/dashboard?token={remoteStatus.token}
+
+
+
+ )}
+
+
+
+ 启用后可在同一局域网内通过浏览器访问本机数据看板(设备列表、告警、巡检快照)。
+ 连接时需带上上方 token,建议仅在可信内网环境使用。
+
+
+
diff --git a/components/inspection/AlertCenterPage.tsx b/components/inspection/AlertCenterPage.tsx
index 1009fbe..6eba470 100644
--- a/components/inspection/AlertCenterPage.tsx
+++ b/components/inspection/AlertCenterPage.tsx
@@ -217,6 +217,56 @@ export function AlertCenterPage() {
setRcaLoading(true);
setRcaResult(null);
try {
+ // 远程浏览器环境:fetch 主进程 HTTP API 做简化版 RCA(不 SSH,只用已有巡检数据+AI)
+ const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ if (isRemote) {
+ const token = (window as any).__REMOTE_TOKEN__ || "";
+ const resp = await fetch(`/api/run-alert-rca?token=${encodeURIComponent(token)}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ alertId: alert.id }),
+ });
+ const data = await resp.json();
+ if (!data.ok) {
+ toast.error("AI 分析失败: " + (data.error || "未知错误"));
+ setRcaLoading(false);
+ return;
+ }
+ // 解析简化版 RCA 结果
+ const parsed = data.rca;
+ if (parsed) {
+ toast.success("AI 分析完成(远程简化版)");
+ // 构建 RunRCAResult 兼容格式
+ const rcaPayload = {
+ __rca_json: true,
+ generated_at: Date.now(),
+ source: "ai_simplified",
+ summary: parsed.summary,
+ confidence: parsed.confidence,
+ rootCauses: parsed.rootCauses,
+ recommendations: parsed.recommendations,
+ };
+ setRcaResult({
+ success: true, source: "ai",
+ rca: { summary: parsed.summary || "", confidence: parsed.confidence ?? 0, rootCauses: parsed.rootCauses || [], recommendations: parsed.recommendations || [], evidence: [] },
+ durationMs: 0,
+ toolInvocations: [{ tool: "ai_simplified_rca", ok: true, durationMs: 0 }],
+ });
+ // 持久化到告警 note(主进程已经写了一份,前端再写一次保证一致)
+ try {
+ const existingNote = alert.note || "";
+ const finalNote = "[RCA_JSON]" + JSON.stringify(rcaPayload);
+ if (existingNote !== finalNote) {
+ // 主进程已经写了,这里刷新一下本地告警列表
+ }
+ } catch (_) {}
+ } else {
+ toast.error("AI 返回结果格式异常");
+ }
+ setRcaLoading(false);
+ return;
+ }
+ // Electron 本地:原有的 runRCA(完整 Phase 1/2 + SSH 诊断)
toast.info("AI 正在分析故障根因...(最多 3 分钟)");
const result = await runRCA(alert);
setRcaResult(result);
diff --git a/components/inspection/InspectionCenterPage.tsx b/components/inspection/InspectionCenterPage.tsx
index dc9d884..fffc3aa 100644
--- a/components/inspection/InspectionCenterPage.tsx
+++ b/components/inspection/InspectionCenterPage.tsx
@@ -5,6 +5,7 @@ import {
Clock,
RefreshCw,
Play,
+ Pause,
Search,
Server,
ListIcon,
@@ -40,6 +41,7 @@ import {
inspectionResultApi,
inspectionExecApi,
inspectionAlertApi,
+ inspectionSchedulerApi,
type DeviceExtension,
type DeviceCatalog,
type InspectionResult,
@@ -72,6 +74,7 @@ interface DeviceRow {
export function InspectionCenterPage() {
const { hosts, keys } = useVaultState();
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
const [extensions, setExtensions] = useState>({});
const [catalogs, setCatalogs] = useState([]);
const [latestResults, setLatestResults] = useState>({});
@@ -79,6 +82,10 @@ export function InspectionCenterPage() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
+ // 批量启用/停止巡检状态
+ const [batchEnabling, setBatchEnabling] = useState(false);
+ const [batchStopping, setBatchStopping] = useState(false);
+
// 筛选
const [searchText, setSearchText] = useState("");
const [filterCatalog, setFilterCatalog] = useState("all");
@@ -128,6 +135,33 @@ export function InspectionCenterPage() {
}
}, []);
+ // 批量启用/停止(必须在 loadData 之后声明,避免 TDZ)
+ const handleBatchEnable = useCallback(async () => {
+ setBatchEnabling(true);
+ try {
+ const res = await inspectionDeviceApi.batchSetEnabled(true);
+ toast.success(`已批量启用 ${res.count} 台设备自动巡检`);
+ await loadData();
+ } catch (e: any) {
+ toast.error(e?.message || "操作失败");
+ } finally {
+ setBatchEnabling(false);
+ }
+ }, [loadData]);
+
+ const handleBatchStop = useCallback(async () => {
+ setBatchStopping(true);
+ try {
+ const res = await inspectionDeviceApi.batchSetEnabled(false);
+ toast.success(`已批量停止 ${res.count} 台设备自动巡检`);
+ await loadData();
+ } catch (e: any) {
+ toast.error(e?.message || "操作失败");
+ } finally {
+ setBatchStopping(false);
+ }
+ }, [loadData]);
+
useEffect(() => {
loadData();
}, [loadData]);
@@ -164,11 +198,33 @@ export function InspectionCenterPage() {
};
}, [loadData]);
+ // 远程浏览器模式下,用定时轮询替代主进程事件推送(每 10 秒自动刷新)
+ useEffect(() => {
+ if (!IS_REMOTE) return;
+ const interval = setInterval(() => {
+ void loadData(true);
+ }, 10000);
+ return () => clearInterval(interval);
+ }, [IS_REMOTE, loadData]);
+
// 构建设备行数据
const deviceRows = useMemo(() => {
- return (hosts || [])
- .filter((h) => h && h.id)
- .map((host) => {
+ // 远程浏览器:vault hosts 不可用,从 inspection extensions + 巡检结果合成虚拟 hosts
+ const effectiveHosts = (hosts && hosts.length > 0)
+ ? hosts
+ : IS_REMOTE
+ ? Object.values(extensions).map((ext: any) => ({
+ id: ext.host_id,
+ label: ext.label || ext.hostname || ext.host_id,
+ hostname: ext.hostname || "",
+ address: ext.hostname || "",
+ platform: "ssh",
+ }))
+ : (hosts || []);
+
+ return effectiveHosts
+ .filter((h: any) => h && h.id)
+ .map((host: any) => {
const ext = extensions[host.id];
const catalog = catalogs.find((c) => c.id === ext?.catalog_id);
const lastResult = latestResults[host.id];
@@ -390,10 +446,12 @@ export function InspectionCenterPage() {
-
+
+
+
{/* 统计卡片 */}
@@ -499,7 +557,32 @@ export function InspectionCenterPage() {
{/* 设备列表 */}
-
+
+
+ 共 {filteredRows.length} 台设备
+
+
+
+
+
+
+
{loading ? (
加载中...
) : filteredRows.length === 0 ? (
diff --git a/components/inspection/ReportPage.tsx b/components/inspection/ReportPage.tsx
index af6443b..bd0edde 100644
--- a/components/inspection/ReportPage.tsx
+++ b/components/inspection/ReportPage.tsx
@@ -410,12 +410,17 @@ export function ReportPage() {
catch { toast.error("清空失败"); }
}, [loadSnapshots]);
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+
const handlePreviewSnapshotPdf = useCallback(async (snapshotId: string) => {
+ if (IS_REMOTE) {
+ const token = (window as any).__REMOTE_TOKEN__ || "";
+ window.open(`/api/report-preview?mode=snapshot&id=${encodeURIComponent(snapshotId)}&token=${encodeURIComponent(token)}`, "_blank");
+ return;
+ }
try {
- // 内嵌预览(BrowserWindow + Electron PDF viewer),不存到 reports 目录
const result = await inspectionReportApi.previewSnapshotPdf(snapshotId);
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
- // 成功的话主进程已经弹出 BrowserWindow,不用再处理
} catch (e: any) {
toast.error("预览失败:" + (e?.message || String(e)));
}
@@ -452,8 +457,12 @@ export function ReportPage() {
}, [snapshots]);
const handlePreviewDailyPdf = useCallback(async (reportId: string) => {
+ if (IS_REMOTE) {
+ const token = (window as any).__REMOTE_TOKEN__ || "";
+ window.open(`/api/report-preview?mode=daily&id=${encodeURIComponent(reportId)}&token=${encodeURIComponent(token)}`, "_blank");
+ return;
+ }
try {
- // 内嵌预览(BrowserWindow + Electron PDF viewer),不存盘到 reports 目录
const result = await inspectionDailyReportApi.previewPdf(reportId);
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
} catch (e: any) {
diff --git a/components/monitoring/MonitoringTopologyView.tsx b/components/monitoring/MonitoringTopologyView.tsx
index dd28415..836f99b 100644
--- a/components/monitoring/MonitoringTopologyView.tsx
+++ b/components/monitoring/MonitoringTopologyView.tsx
@@ -1,4 +1,4 @@
-/**
+/**
* 运行监控 - 3D 拓扑大屏
*
* 移植自 ITYUNWEI TopologyView.vue
@@ -328,7 +328,12 @@ function formatRelativeTime(timestamp: number): string {
// 扩展 Host 的设备类型信息
function classifyHostType(host: Host): string {
- const distro = (host.distro || '').toLowerCase();
+ // 优先用直接的 type 字段(remote API 会返回 switch/server/firewall/router)
+ const directType = (host as any).type;
+ if (directType && ['server', 'switch', 'router', 'firewall', 'storage', 'workstation'].includes(directType)) {
+ return directType;
+ }
+ const distro = (host.distro || (host as any).catalog || (host as any).catalog_id || '').toLowerCase();
const name = (host.name || '').toLowerCase();
const label = (host.label || '').toLowerCase();
const combined = `${distro} ${name} ${label}`;
@@ -476,24 +481,94 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
const [currentTime, setCurrentTime] = useState(new Date());
const [legend, setLegend] = useState(true);
const [showConnectionLegend, setShowConnectionLegend] = useState(true);
- const [monitoringTitle, setMonitoringTitle] = useState
(() =>
- localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE) || '云启Net智能巡检监测系统'
- );
+ const [monitoringTitle, setMonitoringTitle] = useState(() => {
+ // 先给默认值,useEffect 里远程异步加载(时序更安全)
+ if (typeof window !== "undefined") {
+ const saved = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
+ if (saved) return saved;
+ }
+ return '云启Net智能巡检监测系统';
+ });
- // 监听 localStorage 中大屏标题变化(设置页面修改后实时生效)
+ // 远程环境:异步加载标题 / 位置 / 连接线(useState init 阶段 token 可能还没注入)
useEffect(() => {
+ if (typeof window === "undefined") return;
+ const token = (window as any).__REMOTE_TOKEN__;
+ if (!token) return; // 不是远程环境或 token 未就绪
+
+ const fetchJSON = (url: string): Promise =>
+ fetch(url).then(r => r.json()).catch(() => null);
+
+ // 1. 标题
+ fetchJSON(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
+ .then(json => { if (json?.ok && json.title) setMonitoringTitle(json.title); });
+
+ // 2. 拓扑位置
+ fetchJSON(`/api/topology-positions?token=${encodeURIComponent(token)}`)
+ .then(json => {
+ if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
+ setSavedPositions(json.positions);
+ }
+ });
+
+ // 3. 连接线
+ fetchJSON(`/api/topology-links?token=${encodeURIComponent(token)}`)
+ .then(json => { if (json?.ok && Array.isArray(json.links)) setCustomLinks(json.links); });
+ }, []);
+
+ // Electron 环境:监听 localStorage 变化 + mount 时同步 hosts/title 给主进程
+ useEffect(() => {
+ const nc = (window as any).netcatty;
+ console.log('[MonitoringTopologyView sync] nc exists:', !!nc, 'nc.inspection exists:', !!nc?.inspection, 'hosts.length:', hosts?.length);
+ if (!nc?.inspection) {
+ console.log('[MonitoringTopologyView sync] skip — not Electron env');
+ return;
+ }
+
+ // mount 时一次性同步 vault hosts + 标题 + 拓扑位置 + 拓扑连线
+ if (hosts?.length > 0) {
+ console.log('[MonitoringTopologyView sync] saving vault hosts:', hosts.length);
+ nc.inspection.saveVaultHosts(hosts).then((r: any) => console.log('saveVaultHosts result:', r)).catch((e: any) => console.warn('saveVaultHosts error:', e));
+ }
+ const savedTitle = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
+ console.log('[MonitoringTopologyView sync] savedTitle:', savedTitle);
+ if (savedTitle) nc.inspection.saveDashboardTitle(savedTitle).catch(() => {});
+ const savedPos = localStorage.getItem('monitoring-topology-positions');
+ if (savedPos) nc.inspection.saveTopologyPositions(JSON.parse(savedPos)).catch(() => {});
+ const savedLinks = localStorage.getItem('monitoring-topology-links');
+ console.log('[MonitoringTopologyView sync] savedLinks exists:', !!savedLinks);
+ if (savedLinks) {
+ try { nc.inspection.saveTopologyLinks(JSON.parse(savedLinks)).catch(() => {}); } catch {}
+ }
+ if (!savedLinks && nc.inspection.getTopologyLinks) {
+ nc.inspection.getTopologyLinks().then((linksFromMain: any) => {
+ if (Array.isArray(linksFromMain) && linksFromMain.length > 0) {
+ console.info('[MonitoringTopologyView] restore links from main:', linksFromMain.length);
+ localStorage.setItem('monitoring-topology-links', JSON.stringify(linksFromMain));
+ try { setCustomLinks(linksFromMain); } catch {}
+ }
+ }).catch(() => {});
+ }
+
+ const syncToMain = (value: string) => {
+ if (value) nc.inspection.saveDashboardTitle(value).catch(() => {});
+ };
const handler = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_MONITORING_DASHBOARD_TITLE && e.newValue !== null) {
setMonitoringTitle(e.newValue || '云启Net智能巡检监测系统');
+ syncToMain(e.newValue);
}
};
- // 同页 localStorage 变化不走 storage 事件,直接在同页用 CustomEvent 广播
const localHandler = () => {
const v = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
setMonitoringTitle(v || '云启Net智能巡检监测系统');
+ syncToMain(v || '');
};
window.addEventListener('storage', handler);
window.addEventListener('monitoringTitleChanged' as any, localHandler);
+ // mount 时同步一次当前标题
+ const initVal = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
+ if (initVal) syncToMain(initVal);
return () => {
window.removeEventListener('storage', handler);
window.removeEventListener('monitoringTitleChanged' as any, localHandler);
@@ -843,14 +918,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
});
const [pendingLink, setPendingLink] = useState<{ sourceId: string; targetId: string } | null>(null);
- // 用户创建的连线(从 localStorage 加载)
+ // 用户创建的连线 —— 默认空数组,远程/本地各自从 useEffect 加载
const [customLinks, setCustomLinks] = useState(() => {
try {
const saved = localStorage.getItem('monitoring-topology-links');
return saved ? JSON.parse(saved) : [];
- } catch {
- return [];
- }
+ } catch { return []; }
});
// 连线模式使用 ref 避免闭包问题
@@ -884,6 +957,13 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
localStorage.setItem('monitoring-topology-links', JSON.stringify(customLinks));
}, [customLinks]);
+ // 连线变化时同步到主进程 inspection-data.json(供远程浏览器 API 读取)
+ useEffect(() => {
+ const nc = (window as any).netcatty;
+ if (!nc?.inspection) return;
+ nc.inspection.saveTopologyLinks(customLinks).catch(() => {});
+ }, [customLinks]);
+
useEffect(() => {
localStorage.setItem('monitoring-topology-excluded-links', JSON.stringify(excludedAutoLinks));
}, [excludedAutoLinks]);
@@ -902,10 +982,16 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
exitFullscreen();
}
};
+ // 浏览器原生全屏变化(ESC 退出),同步 React 状态
+ const onFsChange = () => {
+ if (!document.fullscreenElement && isFullscreen) {
+ setIsFullscreen(false);
+ }
+ };
if (isFullscreen) {
document.addEventListener('keydown', onKeyDown);
- // body 加 class,配合 CSS 让 monitoring-page 固定全屏
+ document.addEventListener('fullscreenchange', onFsChange);
document.body.classList.add('monitoring-fullscreen-active');
// Electron 环境下同时让窗口也全屏
const nc = (window as any).netcatty;
@@ -916,6 +1002,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
return () => {
document.removeEventListener('keydown', onKeyDown);
+ document.removeEventListener('fullscreenchange', onFsChange);
document.body.classList.remove('monitoring-fullscreen-active');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -923,6 +1010,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
const enterFullscreen = useCallback(() => {
setIsFullscreen(true);
+ // 浏览器远程访问环境:用原生 Fullscreen API 让 body 全屏
+ if (typeof (document.documentElement as any).requestFullscreen === 'function') {
+ (document.documentElement as any).requestFullscreen().catch(() => {});
+ } else if (typeof (document.body as any).webkitRequestFullscreen === 'function') {
+ (document.body as any).webkitRequestFullscreen();
+ }
}, []);
const exitFullscreen = useCallback(() => {
@@ -931,6 +1024,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
if (nc?.windowFullscreen) {
nc.windowFullscreen(false).catch(() => {});
}
+ // 浏览器环境退出原生全屏
+ if (document.fullscreenElement && typeof document.exitFullscreen === 'function') {
+ document.exitFullscreen().catch(() => {});
+ } else if (typeof (document as any).webkitExitFullscreen === 'function') {
+ (document as any).webkitExitFullscreen();
+ }
}, []);
const toggleFullscreen = useCallback(() => {
@@ -966,7 +1065,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
}
}
- return hosts.map((h) => {
+ return monitoring.effectiveHosts.map((h) => {
const type = classifyHostType(h);
const online = monitoring.onlineMap[h.id] ?? true;
const cpuUsage = monitoring.cpuMap[h.id] ?? 0;
@@ -996,9 +1095,14 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
status = 'warning';
}
+ // label 里可能已经包含 IP(如 "外网-192.168.0.221"),去重显示
+ let rawName = h.label || h.id;
+ const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
+ const name = rawName.replace(ipRegex, '').trim().replace(/[-_\s]+$|^[-_\s]+/g, '') || rawName;
+
return {
id: h.id,
- name: h.label || h.name || h.id,
+ name,
ip: h.hostname,
type,
status,
@@ -1011,7 +1115,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
lastUpdate,
};
});
- }, [hosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap, monitoring.alerts, monitoring.thresholds]);
+ }, [monitoring.effectiveHosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap, monitoring.alerts, monitoring.thresholds]);
// 合并自动生成链路和用户自定义链路,并过滤掉源/目标已不存在的僵尸连线
const links = useMemo(() => {
@@ -1706,7 +1810,11 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
const data: Record = saved ? JSON.parse(saved) : {};
data[devId] = { x: p.x, y: clampedY, z: p.z };
localStorage.setItem('monitoring-topology-positions', JSON.stringify(data));
- console.log('[持久化] 位置已保存:', devId, { x: p.x, y: clampedY, z: p.z });
+ // Electron 环境同步给主进程,让远程浏览器也能读到
+ const nc = (window as any).netcatty;
+ if (nc?.inspection?.saveTopologyPositions) {
+ nc.inspection.saveTopologyPositions(data).catch(() => {});
+ }
} catch { /* ignore */ }
};
@@ -2183,6 +2291,22 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
const positions: Record = {};
const savedPositions = (() => {
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ if (IS_REMOTE) {
+ try {
+ const token = (window as any).__REMOTE_TOKEN__ || "";
+ const xhr = new XMLHttpRequest();
+ xhr.open("GET", `/api/topology-positions?token=${encodeURIComponent(token)}`, false);
+ xhr.send();
+ if (xhr.status === 200) {
+ const json = JSON.parse(xhr.responseText);
+ if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
+ return json.positions;
+ }
+ }
+ } catch { /* ignore */ }
+ }
+ // 本地 Electron:从 localStorage 读
try {
const saved = localStorage.getItem('monitoring-topology-positions');
return saved ? JSON.parse(saved) : null;
@@ -2237,6 +2361,20 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
positionsRef.current = positions;
+ // Electron 环境:启动时自动把 localStorage 里的拓扑位置同步给主进程,供远程浏览器读取
+ {
+ const IS_ELECTRON = typeof window !== "undefined" && !!(window as any).netcatty;
+ if (IS_ELECTRON) {
+ try {
+ const saved = localStorage.getItem('monitoring-topology-positions');
+ if (saved) {
+ const nc = (window as any).netcatty;
+ nc.inspection?.saveTopologyPositions?.(JSON.parse(saved)).catch(() => {});
+ }
+ } catch { /* ignore */ }
+ }
+ }
+
// 创建设备节点
const nodes: THREE.Group[] = [];
for (const device of devices) {
@@ -3383,6 +3521,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
setCustomLinks((prev) => [...prev, newLink]);
console.log('[连线] 创建链路:', pendingLink.sourceId, '->', pendingLink.targetId, type);
setPendingLink(null);
+ setLinkMode(null); // 退出连线模式,恢复 cursor 为 grab
}}
>
{label}
diff --git a/components/monitoring/useMonitoringData.ts b/components/monitoring/useMonitoringData.ts
index 6ded247..7064361 100644
--- a/components/monitoring/useMonitoringData.ts
+++ b/components/monitoring/useMonitoringData.ts
@@ -90,6 +90,9 @@ interface UseMonitoringDataResult {
// 操作
refresh: () => Promise;
loading: boolean;
+
+ // 浏览器远程访问环境下实际使用的 hosts(Electron 环境 = props.hosts)
+ effectiveHosts: Host[];
}
// ===== localStorage 持久化(降级用,API 不可用时显示缓存) =====
@@ -321,6 +324,61 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
hostsRef.current = hosts;
keysRef.current = keys;
+ // 浏览器远程访问环境:从 /api/devices 构建虚拟 hosts 列表(Electron vault 不可用)
+ const [remoteHosts, setRemoteHosts] = useState([]);
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+ const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || "";
+
+ // Electron 本地:同步 vault hosts 给主进程(供远程浏览器 API 读)
+ useEffect(() => {
+ if (IS_REMOTE) return; // 远程浏览器不触发
+ const nc = (window as any).netcatty;
+ if (!nc?.inspection) return;
+ if (hosts.length === 0) return;
+ console.log('[useMonitoringData] syncing vault hosts to main process:', hosts.length);
+ nc.inspection.saveVaultHosts(hosts).then((r: any) => {
+ console.log('[useMonitoringData] saveVaultHosts result:', r);
+ }).catch((e: any) => {
+ console.warn('[useMonitoringData] saveVaultHosts error:', e);
+ });
+ }, [hosts, IS_REMOTE]);
+
+ useEffect(() => {
+ if (!IS_REMOTE) return;
+ console.log('[monitoring] REMOTE ENV - props.hosts.length=', hosts.length, 'remoteHosts.length=', remoteHosts.length);
+ if (hosts.length > 0) {
+ console.warn('[monitoring] REMOTE ENV 但 props.hosts 有值!跳过 remote hosts 加载,hosts[0]=', hosts[0]);
+ return;
+ }
+ const loadRemoteHosts = async () => {
+ try {
+ const res = await fetch(`/api/devices?token=${encodeURIComponent(REMOTE_TOKEN)}`);
+ const data = await res.json();
+ const list: Host[] = (data.devices || []).map((d: any) => ({
+ id: d.host_id,
+ label: d.label || d.name || d.hostname || d.host_id,
+ hostname: d.hostname || d.name || "",
+ port: 22,
+ username: "",
+ distro: d.distro || d.catalog_type || d.catalog_id || "",
+ type: d.type || "",
+ catalogId: d.catalog_id || "",
+ tags: [],
+ os: "linux" as const,
+ group: "",
+ }));
+ setRemoteHosts(list);
+ hostsRef.current = list;
+ } catch (e) {
+ console.warn("[monitoring] remote hosts load failed:", e);
+ }
+ };
+ void loadRemoteHosts();
+ }, [IS_REMOTE, REMOTE_TOKEN, hosts.length]);
+
+ // 实际使用的 hosts:Electron 用 props,浏览器用远程加载的
+ const effectiveHosts = IS_REMOTE && hosts.length === 0 ? remoteHosts : hosts;
+
const refresh = useCallback(async (silent = false) => {
if (loadingRef.current) {
console.log('[monitoring] refresh() 正在执行中,跳过');
@@ -505,33 +563,33 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
// 自动启动调度器
if (enabledDevices.length === 0) {
console.log('[monitoring] 没有启用巡检的设备');
- } else if (schedulerStatus.running) {
- console.log('[monitoring] 调度器已在运行中');
} else {
- console.log('[monitoring] 条件满足,准备启动调度器');
+ console.log('[monitoring] 条件满足,准备启动调度器');
try {
const hostConfigs: Record> = {};
- for (const dev of enabledDevices) {
- const hostname = dev.hostname;
- if (!hostname) {
- console.log('[monitoring] 设备', dev.host_id, '没有 hostname,跳过');
- continue;
- }
- const host = hostsRef.current.find((h) => h.hostname === hostname);
+ const allExt = allExtensions || {};
+ for (const [hostId, ext] of Object.entries(allExt)) {
+ if (!ext || !ext.inspection_enabled) continue;
+ const host = hostsRef.current.find((h) => h.id === hostId);
if (!host) {
- console.log('[monitoring] vault 中未找到 hostname:', hostname, ',跳过');
+ console.log('[monitoring] vault 中未找到 host_id:', hostId, ',跳过');
continue;
}
if (!host.username) {
- console.log('[monitoring] 设备', hostname, '缺少 username,跳过');
+ console.log('[monitoring] 设备', host.label || host.hostname || hostId, '缺少 username,跳过');
continue;
}
- hostConfigs[dev.host_id] = buildSshConfig(host, keysRef.current);
+ hostConfigs[hostId] = buildSshConfig(host, keysRef.current);
}
+ // 诊断:打印 hostConfigs 样本看 label 有没有正确传递
+ const sample = Object.entries(hostConfigs).slice(0, 3).map(([id, c]) => ({ id, label: c.label, host: c.host, username: c.username }));
+ console.log('[monitoring] hostConfigs 样本:', JSON.stringify(sample));
if (Object.keys(hostConfigs).length === 0) {
- console.log('[monitoring] 没有可巡检的设备(hostname 未写入或配置不完整)');
+ console.log('[monitoring] 没有可巡检的设备(vault 中匹配不到或凭据不完整)');
} else {
- console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备');
+ console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备(running=' + schedulerStatus.running + ')');
+ // 不管 running 与否都传 hostConfigs 过去让主进程更新,
+ // 主进程 execFn 通过模块级 _hostConfigs 引用动态读最新值
await inspectionSchedulerApi.start(hostConfigs);
setSchedulerRunning(true);
}
@@ -586,7 +644,7 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
setOnlineMap((prev) => {
const next: Record = { ...prev };
let changed = false;
- for (const h of hosts) {
+ for (const h of effectiveHosts) {
if (next[h.id] === undefined) {
next[h.id] = true; // 假设在线,等巡检结果覆盖
changed = true;
@@ -594,7 +652,7 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
}
return changed ? next : prev;
});
- }, [hosts]);
+ }, [effectiveHosts]);
// 从告警规则派生阈值(和 alertRuleStore 默认规则对齐)
const thresholds = useMemo(() => {
@@ -636,5 +694,6 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
thresholds,
refresh,
loading,
+ effectiveHosts,
};
}
diff --git a/components/vault/VaultViewLayout.tsx b/components/vault/VaultViewLayout.tsx
index 9780bc7..b117876 100644
--- a/components/vault/VaultViewLayout.tsx
+++ b/components/vault/VaultViewLayout.tsx
@@ -135,6 +135,8 @@ function VaultConnectionLogsSection({
}
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
+ const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+
// 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件
const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh');
diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs
index dc92f85..61c5c5f 100644
--- a/electron-builder.config.cjs
+++ b/electron-builder.config.cjs
@@ -1,10 +1,18 @@
+const path = require('node:path');
const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs');
const { etExtraResources } = require('./scripts/et-extra-resources.cjs');
+// 说明:electron-builder 依赖收集器在 monorepo/workspace 项目里会漏掉
+// workspace 子包引用但根 package.json 没声明的运行时依赖(hoist 上来的),
+// 且追踪传递依赖时容易漏包。这里直接把整个 node_modules 目录纳入 files,
+// 确保运行时 require 什么都能找到。用排除列表去掉前端/构建工具/文档/测试代码。
/**
* @type {import('electron-builder').Configuration}
*/
module.exports = {
+ // 内网环境下 electron-builder 默认会去 github.com 下载 Electron zip。
+ // 强制指向本地已解压的 node_modules/electron/dist,跳过联网。
+ electronDist: path.join(__dirname, 'node_modules', 'electron', 'dist'),
appId: 'com.netcatty.app',
productName: 'NetMesh',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
@@ -50,117 +58,19 @@ module.exports = {
output: 'release'
},
files: [
- 'dist/**/*',
- 'electron/**/*',
- // The Windows Hello helper is shipped once through win.extraResources.
- // Exclude local per-arch build output from app.asar on every platform.
- '!electron/bridges/windowsHelloHelper/build/**/*',
- // Runtime smoke fixtures are built test packages, not host resources.
- // Keep them out of production ASARs so no example plugin can be
- // mistaken for an installed or host-trusted package.
- '!electron/plugins/fixtures/**/*',
- // Main-process terminal flow control reads shared thresholds from here
- // (terminalFlowAck.cjs). Must ship beside electron/ in app.asar.
- 'infrastructure/config/terminalFlowConstants.cjs',
- 'infrastructure/config/terminalFlowConstants.json',
- // Renderer and Mosh's main-process bootstrap share the same
- // fail-closed prompt classifier at runtime.
- 'domain/terminalPromptSecurity.shared.cjs',
- 'lib/**/*.cjs',
- 'lib/**/*.json',
- 'skills/**/*',
- '!public/**/*',
- '!**/*.map',
- '!**/*.d.ts',
- '!**/*.d.mts',
- '!**/*.d.cts',
- '!**/*.ts',
- '!**/*.tsx',
- '!**/*.test.*',
- '!**/*.spec.*',
- '!**/__tests__/**/*',
- '!**/test/**/*',
- '!**/tests/**/*',
- '!**/example/**/*',
- '!**/examples/**/*',
- '!node_modules/**/docs/**/*',
- '!node_modules/**/doc/**/*',
- '!node_modules/**/benchmark/**/*',
- '!node_modules/**/benchmarks/**/*',
- // Renderer-only packages are compiled into dist by Vite. Keep them
- // installed for npm run dev/build, but do not ship the duplicate source
- // packages in release artifacts.
- '!node_modules/@fontsource/**/*',
- '!node_modules/@monaco-editor/**/*',
- '!node_modules/@radix-ui/**/*',
- '!node_modules/@xterm/**/*',
- '!node_modules/lucide-react/**/*',
- '!node_modules/monaco-editor/**/*',
- '!node_modules/react/**/*',
- '!node_modules/react-dom/**/*',
- '!node_modules/ai/**/*',
- '!node_modules/@ai-sdk/**/*',
- '!node_modules/@mdxeditor/**/*',
- '!node_modules/streamdown/**/*',
- '!node_modules/@streamdown/**/*',
- '!node_modules/@tanstack/react-virtual/**/*',
- '!node_modules/pinyin-pro/**/*',
- '!node_modules/re2js/**/*',
- '!node_modules/@eslint-community/regexpp/**/*',
- '!node_modules/clsx/**/*',
- '!node_modules/tailwind-merge/**/*',
- '!node_modules/use-stick-to-bottom/**/*',
- '!node_modules/lexical/**/*',
- '!node_modules/@lexical/**/*',
- '!node_modules/@codemirror/**/*',
- '!node_modules/katex/**/*',
- '!node_modules/shiki/**/*',
- '!node_modules/@shiki/**/*',
- // Heavy cloud completion specs are intentionally not bundled. The main
- // process filters the same prefixes so dev and packaged builds behave
- // consistently.
- '!node_modules/@withfig/autocomplete/build/aws.js',
- '!node_modules/@withfig/autocomplete/build/aws/**/*',
- '!node_modules/@withfig/autocomplete/build/gcloud.js',
- '!node_modules/@withfig/autocomplete/build/gcloud/**/*',
- '!node_modules/@withfig/autocomplete/build/az/**/*',
- // Fig specs are already compiled JavaScript; TypeScript is only pulled
- // in by Fig helper packages as build tooling and is not needed at app
- // runtime.
- '!node_modules/typescript/**/*',
- // ── Exclude per-platform native agent binaries (~100s of MB each). ──
- // Netcatty is "bring your own CLI": each SDK is pointed at the user's
- // system-installed CLI via an absolute path override (claude
- // pathToClaudeCodeExecutable / codex codexPathOverride / copilot cliPath).
- // Only the SDKs' JS is bundled; the heavy per-arch binaries are dropped.
- // NOTE: claude-agent-sdk vendors the `claude` binary as a NESTED package
- // (claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-),
- // so this exclusion must match at any depth (**/), not just top-level.
- // The codex/copilot exclusions target only the per-arch binary packages
- // (codex- / copilot-) — NOT @openai/codex-sdk / copilot-sdk,
- // whose JS we DO bundle. @github/copilot is the full ~288MB CLI (with
- // per-platform prebuilds); netcatty uses the user's copilot via cliPath,
- // so it is excluded entirely.
- '!**/@anthropic-ai/claude-agent-sdk-*/**/*',
- '!node_modules/@anthropic-ai/claude-code-*/**/*',
- '!node_modules/@openai/codex-{darwin,linux,linuxmusl,win32}-*/**/*',
- '!node_modules/@github/copilot-{darwin,linux,linuxmusl,win32}-*/**/*',
- '!node_modules/@github/copilot/**/*',
- '!node_modules/opencode-{darwin,linux,linuxmusl,windows}-*/**/*',
- '!node_modules/opencode-ai/**/*',
- // CodeBuddy follows the same first-party integration model as the
- // other coding agents: Netcatty discovers and passes the user's
- // installed CLI path to the SDK. Keep the small SDK wrapper, but do not
- // bundle the full CodeBuddy CLI payload (rg vendors + web UI).
- '!node_modules/@tencent-ai/agent-sdk/cli/**/*',
- // Netcatty loads Cursor SDK through ESM dynamic import, so the duplicate
- // CommonJS build and type metadata are not needed at runtime.
- '!node_modules/@cursor/sdk/dist/cjs/**/*',
- '!node_modules/@cursor/sdk/dist/**/*.d.ts',
- '!node_modules/@cursor/sdk/dist/**/*.d.ts.map',
- // sqlite3 rebuilds a native module for Electron; its upstream source
- // tarball is build-time payload only.
- '!node_modules/sqlite3/deps/**/*'
+ // electron-builder 收集器只读根 package.json,但 workspace 子包的运行时依赖
+ // 会 hoist 到根 node_modules 却没在根 package.json 里声明。
+ // 收集器会漏掉这些包,所以把漏的补进根 package.json 后它就能追踪完整传递依赖树。
+ "dist/**/*",
+ "electron/**/*",
+ "!electron/bridges/windowsHelloHelper/build/**/*",
+ "!electron/plugins/fixtures/**/*",
+ "infrastructure/config/terminalFlowConstants.cjs",
+ "infrastructure/config/terminalFlowConstants.json",
+ "domain/terminalPromptSecurity.shared.cjs",
+ "lib/**/*.cjs",
+ "lib/**/*.json",
+ "skills/**/*"
],
asarUnpack: [
'node_modules/node-pty/**/*',
diff --git a/electron/bridges/aiBridge.cjs b/electron/bridges/aiBridge.cjs
index d2ab737..082f520 100644
--- a/electron/bridges/aiBridge.cjs
+++ b/electron/bridges/aiBridge.cjs
@@ -241,6 +241,19 @@ const activeStreams = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
const ENC_PREFIX = "enc:v1:";
let providerConfigs = [];
+const _PROVIDER_STORAGE_KEY = "_ai_provider_configs";
+
+function loadProviderConfigsFromStorage() {
+ try {
+ const { readStorage } = require("./inspection/inspectionStorage.cjs");
+ const raw = readStorage(_PROVIDER_STORAGE_KEY);
+ if (Array.isArray(raw) && raw.length > 0) {
+ providerConfigs = raw;
+ console.log("[aiBridge] 从 inspectionStorage 加载 providerConfigs:", raw.length, "条");
+ }
+ } catch {}
+ return providerConfigs;
+}
// Web search config (synced from renderer — apiKey stays encrypted, decrypted on use)
let webSearchApiHost = null;
let webSearchApiKeyEncrypted = null;
@@ -930,7 +943,10 @@ function createHandlerContext(ipcMain) {
get cliDiscoveryFilePath() { return cliDiscoveryFilePath; },
set cliDiscoveryFilePath(value) { cliDiscoveryFilePath = value; },
activeStreams,
- get providerConfigs() { return providerConfigs; },
+ get providerConfigs() {
+ if (!providerConfigs || providerConfigs.length === 0) loadProviderConfigsFromStorage();
+ return providerConfigs;
+ },
set providerConfigs(value) { providerConfigs = value; },
get webSearchApiHost() { return webSearchApiHost; },
set webSearchApiHost(value) { webSearchApiHost = value; },
@@ -987,6 +1003,21 @@ function registerHandlers(ipcMain) {
if (externalMcpController) {
externalMcpController.registerHandlers(ipcMain, validateSenderOrSettings);
}
+
+ // 启动时从 inspectionStorage 加载持久化的 provider configs
+ // 解决重启后或远程浏览器场景下 providerConfigs 为空的问题
+ try {
+ const inspectionStorage = require("./inspection/inspectionStorage.cjs");
+ const raw = inspectionStorage.readStorage("_ai_provider_configs");
+ if (Array.isArray(raw) && raw.length > 0) {
+ providerConfigs = raw;
+ console.log("[aiBridge] 启动时从 inspectionStorage 加载 providerConfigs:", raw.length, "条");
+ } else {
+ console.log("[aiBridge] inspectionStorage 中无持久化 providerConfigs(renderer 还未 sync 过)");
+ }
+ } catch (e) {
+ console.warn("[aiBridge] 启动时加载 providerConfigs 失败:", e?.message);
+ }
}
// Abort active streams and child processes on shutdown
@@ -1046,4 +1077,9 @@ module.exports = {
DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS,
getExternalMcpController: () => externalMcpController,
get aiBridgeContext() { return registeredContext; },
+ get providerConfigs() { return providerConfigs; },
+ resolveProviderApiKey,
+ injectApiKeyIntoRequest,
+ shouldSkipTLSVerify,
+ decryptApiKeyValue,
};
diff --git a/electron/bridges/aiBridge/providerHandlers.cjs b/electron/bridges/aiBridge/providerHandlers.cjs
index 2cbae96..85366bb 100644
--- a/electron/bridges/aiBridge/providerHandlers.cjs
+++ b/electron/bridges/aiBridge/providerHandlers.cjs
@@ -60,6 +60,11 @@ function registerProviderHandlers(ctx) {
if (Array.isArray(providers)) {
providerConfigs = providers;
rebuildProviderFetchHosts();
+ // 持久化到 inspectionStorage,让 remoteHttpServer / 主进程内部 RCA 能读到
+ try {
+ const { writeStorage } = require("../inspection/inspectionStorage.cjs");
+ writeStorage("_ai_provider_configs", providers);
+ } catch {}
}
return { ok: true };
});
diff --git a/electron/bridges/inspection/alertRecordStore.cjs b/electron/bridges/inspection/alertRecordStore.cjs
index 90f9489..40c9cde 100644
--- a/electron/bridges/inspection/alertRecordStore.cjs
+++ b/electron/bridges/inspection/alertRecordStore.cjs
@@ -1,4 +1,4 @@
-"use strict";
+"use strict";
/**
* 告警记录存储
@@ -45,8 +45,10 @@ function _ensureLoaded(readFn) {
}
function _safeParse(text, def) {
+ if (text === null || text === undefined) return def;
+ if (typeof text === "object") return text;
try { return JSON.parse(text); }
- catch (e) { return def; }
+ catch { return def; }
}
function _saveAlerts(writeFn) {
diff --git a/electron/bridges/inspection/alertRuleStore.cjs b/electron/bridges/inspection/alertRuleStore.cjs
index cdb2cd1..864f497 100644
--- a/electron/bridges/inspection/alertRuleStore.cjs
+++ b/electron/bridges/inspection/alertRuleStore.cjs
@@ -1,4 +1,4 @@
-"use strict";
+"use strict";
/**
* 告警规则数据存储
@@ -33,8 +33,10 @@ function _ensureLoaded(readFn) {
}
function _safeParse(text, def) {
+ if (text === null || text === undefined) return def;
+ if (typeof text === "object") return text;
try { return JSON.parse(text); }
- catch (e) { return def; }
+ catch { return def; }
}
function _save(writeFn) {
diff --git a/electron/bridges/inspection/dailyReportStore.cjs b/electron/bridges/inspection/dailyReportStore.cjs
index 4a51cfa..5d2717b 100644
--- a/electron/bridges/inspection/dailyReportStore.cjs
+++ b/electron/bridges/inspection/dailyReportStore.cjs
@@ -1,4 +1,4 @@
-"use strict";
+"use strict";
/**
* 日运行报告存储 + 聚合
@@ -30,6 +30,8 @@ const { STORAGE_KEY_DAILY_REPORTS } = require("./storageKeys.cjs");
const MAX_DAILY_REPORTS = 120; // 保留最近约 4 个月(每天 1 条)
function _safeParse(raw, def) {
+ if (raw === null || raw === undefined) return def;
+ if (typeof raw === "object") return raw;
try { return JSON.parse(raw); }
catch { return def; }
}
@@ -276,6 +278,8 @@ function clearAll(readFn, writeFn) {
_save(writeFn);
}
+function _resetForTest() { _reports = null; }
+
module.exports = {
MAX_DAILY_REPORTS,
buildDailyReport,
@@ -286,4 +290,5 @@ module.exports = {
_dayStart,
_dayEnd,
_dateStr,
+ _resetForTest,
};
diff --git a/electron/bridges/inspection/deviceStore.cjs b/electron/bridges/inspection/deviceStore.cjs
index afbb8a7..615bad1 100644
--- a/electron/bridges/inspection/deviceStore.cjs
+++ b/electron/bridges/inspection/deviceStore.cjs
@@ -58,6 +58,9 @@ function _ensureLoaded(readFn) {
function _safeParse(text, def) {
if (text === null || text === undefined) return def;
+ // 已经是对象/数组 → 直接返回
+ if (typeof text === "object") return text;
+ if (typeof text !== "string") return def;
let parsed;
try { parsed = JSON.parse(text); }
catch (e) { return def; }
diff --git a/electron/bridges/inspection/index.cjs b/electron/bridges/inspection/index.cjs
index 0434e8e..66f1d44 100644
--- a/electron/bridges/inspection/index.cjs
+++ b/electron/bridges/inspection/index.cjs
@@ -36,16 +36,26 @@ let _scheduler = null;
let _isSnapshotRunning = false; // 巡检执行状态(主进程持有,renderer 可查询)
// 读报告产品名(PDF 巡检报告标题专用)
-// 优先级:netcatty_report_product_name_v1 > netcatty_product_name_v1 > 默认值
+// 优先级:_report_product_name(新)> _product_name(新)> report_product_name_v1(旧带前缀)> product_name_v1(旧带前缀)> 默认值
function _readProductName(readFn) {
+ // 新 key(IPC save 写的)
+ try {
+ const raw = readFn("_report_product_name");
+ if (typeof raw === "string" && raw.trim()) return raw.trim();
+ } catch (_) {}
+ try {
+ const raw = readFn("_product_name");
+ if (typeof raw === "string" && raw.trim()) return raw.trim();
+ } catch (_) {}
+ // 旧带前缀 key(可能 JSON.stringify 过)
try {
const raw = readFn("netcatty_report_product_name_v1");
- const name = raw ? JSON.parse(raw) : null;
+ const name = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
if (typeof name === "string" && name.trim()) return name.trim();
} catch (_) {}
try {
const raw = readFn("netcatty_product_name_v1");
- const name = raw ? JSON.parse(raw) : null;
+ const name = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
if (typeof name === "string" && name.trim()) return name.trim();
} catch (_) {}
return process.env.NETCATTY_PRODUCT_NAME || "NetMesh 智能巡检系统";
@@ -185,25 +195,28 @@ function _buildSnapshotPrompt(snapshot) {
if (isOffline) {
offlineDevices.push({
device: d.hostname,
- reason: (cmds[0]?.stderr || cmds[0]?.stdout || "SSH连接失败").slice(0, 200),
+ reason: (cmds[0]?.stderr || cmds[0]?.stdout || "SSH连接失败").slice(0, 150),
});
} else {
+ // 大幅压缩:只保留失败命令 + 最多 5 条关键资源命令,输出截断到 200 字符
const detailCmds = [];
let hasFail = false;
+ let resourceCount = 0;
+ const MAX_RESOURCE = 5;
for (const c of cmds) {
if (!c.ok) {
hasFail = true;
detailCmds.push({
- cmd: c.cmd, purpose: c.purpose, status: "fail",
- output: (c.stdout || c.stderr || "").slice(0, 600),
+ cmd: c.cmd, status: "fail",
+ output: (c.stdout || c.stderr || "").slice(0, 200),
});
continue;
}
const cls = _classifyCmd(c.cmd);
- if (cls === "resource") {
- detailCmds.push({ cmd: c.cmd, purpose: c.purpose, status: "ok", output: (c.stdout || "").slice(0, 400) });
- } else if (cls === "log") {
- detailCmds.push({ cmd: c.cmd, purpose: c.purpose, status: "ok", output: _truncateStdout(c.cmd, c.stdout || "") });
+ // 只保留 resource 类型(cpu/mem/磁盘/端口等关键指标),跳过 log 类型
+ if (cls === "resource" && resourceCount < MAX_RESOURCE) {
+ resourceCount++;
+ detailCmds.push({ cmd: c.cmd, status: "ok", output: (c.stdout || "").slice(0, 200) });
}
}
onlineDevices.push({
@@ -218,17 +231,20 @@ function _buildSnapshotPrompt(snapshot) {
}
const totalCmds = onlineDevices.reduce((a, d) => a + d.command_total, 0);
const failCmds = onlineDevices.reduce((a, d) => a + d.command_failed, 0);
+ // 压缩 JSON:用 null 替代缩进,减少 30-40% 体积
return JSON.stringify({
- timestamp: new Date(snapshot.timestamp).toISOString(),
- trigger: snapshot.trigger,
- duration_ms: snapshot.duration_ms,
- offline_count: offlineDevices.length,
- online_count: onlineDevices.length,
- total_commands: totalCmds,
- failed_commands: failCmds,
- offline_devices: offlineDevices,
- online_devices: onlineDevices,
- }, null, 2);
+ offline: offlineDevices,
+ online: onlineDevices,
+ summary: {
+ ts: new Date(snapshot.timestamp).toISOString(),
+ trigger: snapshot.trigger,
+ dur_ms: snapshot.duration_ms,
+ offline_n: offlineDevices.length,
+ online_n: onlineDevices.length,
+ total_cmd: totalCmds,
+ fail_cmd: failCmds,
+ },
+ });
}
function _resolveAiStyle(provider) {
@@ -411,6 +427,25 @@ function registerInspectionHandlers(ipcMain, options) {
return deviceStore.upsertExtension(readFn, writeFn, hostId, data);
});
+ // 批量设置所有设备的 inspection_enabled
+ ipcMain.handle("inspection:batchSetInspectionEnabled", (_e, enabled) => {
+ try {
+ const all = deviceStore.getAllExtensions(readFn);
+ let count = 0;
+ for (const ext of Object.values(all)) {
+ deviceStore.upsertExtension(readFn, writeFn, ext.host_id, {
+ inspection_enabled: !!enabled,
+ });
+ count++;
+ }
+ console.log(`[Inspection] 批量设置巡检状态: ${enabled ? "启用" : "停止"} ${count} 台设备`);
+ return { ok: true, count, enabled: !!enabled };
+ } catch (e) {
+ console.error("[Inspection] 批量设置巡检状态失败:", e);
+ return { ok: false, error: e.message };
+ }
+ });
+
ipcMain.handle("inspection:deleteExtension", (_e, hostId) => {
return deviceStore.deleteExtension(readFn, writeFn, hostId);
});
@@ -811,6 +846,318 @@ function registerInspectionHandlers(ipcMain, options) {
}
});
+ // ---------- AI RCA 完整实现(主进程版) ----------
+ // 用 _hostConfigs 直接拿 SSH 凭据,不需要 renderer 解密
+ // 支持 IPC handler(Electron 本地)和 remoteHttpServer 直接调用
+ const bridge = require("../aiBridge.cjs");
+
+ // 从 AI 响应中提取 JSON(剥离 ...、```json 包裹、找第一个配对的 {...})
+ function _extractJson(text) {
+ let cleaned = (text || "")
+ .replace(/[\s\S]*?<\/think>/gi, "") // 剥离思维链
+ .replace(/```json\s*/gi, "")
+ .replace(/```\s*/g, "")
+ .trim();
+ // 直接尝试 parse
+ try { return JSON.parse(cleaned); } catch {}
+ // 找第一个配对的 {...}
+ const openIdx = cleaned.indexOf("{");
+ if (openIdx >= 0) {
+ let depth = 0, closeIdx = -1;
+ for (let i = openIdx; i < cleaned.length; i++) {
+ if (cleaned[i] === "{") depth++;
+ else if (cleaned[i] === "}") { depth--; if (depth === 0) { closeIdx = i; break; } }
+ }
+ if (closeIdx > openIdx) {
+ const jsonStr = cleaned.slice(openIdx, closeIdx + 1);
+ try { return JSON.parse(jsonStr); } catch {}
+ }
+ }
+ throw new Error("无法从 AI 响应中提取 JSON,原始响应前 200 字: " + cleaned.slice(0, 200));
+ }
+
+ const PHASE1_SYSTEM = [
+ `你是 NetMesh 运维平台的 AI 诊断规划专家。`,
+ `## 设备 Catalog 与命令族`,
+ `| catalogId | 设备类型 | Shell | 推荐诊断命令 |`,
+ `|---|---|---|---|`,
+ `| linux | Linux 服务器 | bash/sh | top -bn1 | head -20, vmstat 1 2, free -m, cat /proc/meminfo | head -20, df -h, ps aux --sort=-%cpu | head -10, iostat -x 1 1 |`,
+ `| huawei_h3c_switch | 华为/H3C 交换机 | HUAWEI CLI | screen-length 0 temporary, display version, display device, display memory, display power, display fan, display logbuffer level warning size 512 |`,
+ `| cisco_switch | Cisco 交换机 | IOS CLI | terminal length 0, show version, show processes cpu sorted, show memory summary, show interfaces brief, show log tail 50 |`,
+ `## 输出格式(严格 JSON)`,
+ `{ "commands": [{ "command": "xxx", "purpose": "xxx" }], "reasoning": "一句话说明" }`,
+ `## 规则`,
+ `- 命令必须严格匹配 catalogId,不得臆造`,
+ `- 禁止 display cpu-usage(有刷新动画抓不到完整输出)`,
+ `- 华为/H3C 必须先加 screen-length 0 temporary 关闭分页`,
+ `- 不超过 6 条命令`,
+ `- 禁止输出外网 URL / 厂商文档链接`,
+ ].join("\n");
+
+ const PHASE2_SYSTEM = [
+ `你是 NetMesh 智慧运维平台的 AI 根因分析专家。`,
+ `你收到了告警 + 设备巡检 metrics + 多条诊断命令执行输出。`,
+ `## 输出格式(严格 JSON)`,
+ `{`,
+ ` "confidence": 85,`,
+ ` "rootCauses": ["最可能根因", "次要可能性"],`,
+ ` "summary": "2-3 句摘要",`,
+ ` "recommendations": [`,
+ ` { "priority": "immediate|short_term|long_term", "action": "要做什么", "command": "可选命令", "risk": "low|medium|high" }`,
+ ` ]`,
+ `}`,
+ `## 规则`,
+ `- confidence 基于证据充分性`,
+ `- recommendations 至少 2 条最多 5 条,按紧急程度排序`,
+ `- 严禁输出外网 URL / 厂商文档 / 外部工具推荐`,
+ ].join("\n");
+
+ async function _callAiForRCA(providerConfigsOrNull, systemPrompt, userContent, maxTokens = 2000) {
+ if (!providerConfigsOrNull || !providerConfigsOrNull.length) {
+ throw new Error("AI provider 未配置");
+ }
+ const provider = providerConfigsOrNull.find(p => p.enabled !== false);
+ if (!provider) throw new Error("没有启用的 AI provider");
+ const resolved = bridge.resolveProviderApiKey(provider.id);
+ if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
+
+ const url = provider.endpoint || "https://api.minimaxi.com/v1/chat/completions";
+ const model = provider.model || "MiniMax-M1";
+ const patched = bridge.injectApiKeyIntoRequest(url, {}, provider.id);
+
+ const http = require("http"), https = require("https");
+ const lib = patched.url.startsWith("https") ? https : http;
+ const u = new URL(patched.url);
+ const bodyStr = JSON.stringify({
+ model,
+ messages: [
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userContent },
+ ],
+ temperature: 0.3,
+ max_tokens: maxTokens,
+ stream: false,
+ });
+
+ return await new Promise((resolve, reject) => {
+ const req = lib.request({
+ hostname: u.hostname,
+ port: u.port || (u.protocol === "https:" ? 443 : 80),
+ path: u.pathname + u.search,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${resolved.apiKey}`,
+ },
+ timeout: 90000,
+ }, res => {
+ let chunks = [];
+ res.on("data", c => chunks.push(c));
+ res.on("end", () => {
+ try {
+ const text = Buffer.concat(chunks).toString();
+ const parsed = JSON.parse(text);
+ const content = parsed.choices?.[0]?.message?.content
+ || parsed.content?.[0]?.text
+ || parsed.candidates?.[0]?.content?.parts?.[0]?.text
+ || "";
+ resolve(content.trim());
+ } catch (e) {
+ reject(new Error("AI 响应解析失败: " + e.message));
+ }
+ });
+ });
+ req.on("error", reject);
+ req.on("timeout", () => { req.destroy(); reject(new Error("AI 请求超时")); });
+ req.write(bodyStr);
+ req.end();
+ });
+ }
+
+ async function runAlertRCA(alertId, providerConfigsOverride) {
+ const start = Date.now();
+ const results = { toolInvocations: [] };
+
+ function resetCaches() {
+ try { deviceStore._resetForTest?.(); } catch {}
+ try { alertRecordStore._resetForTest?.(); } catch {}
+ try { inspectionResultStore._resetForTest?.(); } catch {}
+ try { alertRuleStore._resetForTest?.(); } catch {}
+ }
+
+ // 1. 找告警
+ resetCaches();
+ const alerts = alertRecordStore.listAlerts(readFn, { limit: 500 });
+ const alert = alerts.find(a => a.id === alertId);
+ if (!alert) return { success: false, error: "告警不存在", durationMs: 0, toolInvocations: [] };
+
+ // 2. 找 hostConfig
+ let hostConfig = null;
+ if (_hostConfigs && _hostConfigs[alert.device_id]) {
+ const hc = _hostConfigs[alert.device_id];
+ hostConfig = {
+ hostname: hc.host || hc.hostname,
+ port: hc.port || 22,
+ username: hc.username,
+ password: hc.password,
+ privateKeyContent: hc.privateKey || hc.privateKeyContent,
+ passphrase: hc.passphrase,
+ };
+ }
+
+ // 3. 找 catalog_id + 巡检 metrics
+ let catalogId = "";
+ const extMap = deviceStore.getAllExtensions(readFn);
+ if (extMap[alert.device_id]?.catalog_id) catalogId = extMap[alert.device_id].catalog_id;
+
+ let metrics = {};
+ const latest = inspectionResultStore.getLatestAll(readFn);
+ const result = latest.find(r => r?.device_id === alert.device_id);
+ if (result) metrics = result.metrics || {};
+
+ const alertInfo = {
+ device_name: alert.device_name,
+ device_id: alert.device_id,
+ level: alert.level,
+ metric: alert.metric,
+ metric_value: alert.metric_value,
+ threshold: alert.threshold,
+ rule_name: alert.rule_name,
+ triggered_at: alert.triggered_at,
+ message: alert.message,
+ catalog_id: catalogId || "unknown",
+ online: !!result?.online,
+ };
+
+ const providerConfigs = providerConfigsOverride || bridge.providerConfigs;
+
+ // 4. Phase 1 — AI 规划诊断命令
+ let phase1Plan = { commands: [] };
+ try {
+ const phase1Input = JSON.stringify({
+ alert: alertInfo,
+ metrics,
+ catalog_id: catalogId,
+ device_context: `设备名: ${alert.device_name}, 设备ID: ${alert.device_id}`,
+ });
+ const phase1Text = await _callAiForRCA(providerConfigs, PHASE1_SYSTEM, phase1Input, 1000);
+ const js = _extractJson(phase1Text);
+ phase1Plan = js;
+ console.log("[RCA] Phase 1 规划:", phase1Plan.commands?.length, "条命令");
+ } catch (e) {
+ console.warn("[RCA] Phase 1 失败,跳过 SSH 诊断:", e.message);
+ results.toolInvocations.push({ tool: "phase1_planning", ok: false, durationMs: Date.now() - start, error: e.message });
+ }
+
+ // 5. SSH 执行诊断命令(仅当 hostConfig 可用时)
+ const evidence = [];
+ let allCmdOutputs = "";
+ if (hostConfig && phase1Plan.commands?.length) {
+ console.log("[RCA] 开始 SSH 诊断,目标:", hostConfig.hostname, "命令数:", phase1Plan.commands.length);
+ for (const cmd of phase1Plan.commands) {
+ const cmdStart = Date.now();
+ try {
+ const sshRes = await execSshCommand({
+ host: hostConfig.hostname,
+ port: hostConfig.port || 22,
+ username: hostConfig.username,
+ password: hostConfig.password,
+ privateKey: hostConfig.privateKeyContent,
+ passphrase: hostConfig.passphrase,
+ }, cmd.command, { connectTimeoutMs: 15000, execTimeoutMs: 20000 });
+ const output = (sshRes || "").toString();
+ const truncated = output.length > 2000 ? output.slice(-2000) : output;
+ allCmdOutputs += `\n### ${cmd.command}\n${truncated}\n`;
+ evidence.push({
+ tool: "ssh_command",
+ summary: `${cmd.command}: ${output.length} 字节`,
+ keyFindings: [truncated.slice(0, 300)],
+ });
+ results.toolInvocations.push({ tool: cmd.command, ok: true, durationMs: Date.now() - cmdStart });
+ } catch (e) {
+ results.toolInvocations.push({ tool: cmd.command, ok: false, durationMs: Date.now() - cmdStart, error: e.message });
+ evidence.push({ tool: "ssh_command", summary: `${cmd.command}: 失败 - ${e.message}`, keyFindings: [] });
+ }
+ }
+ } else if (!hostConfig) {
+ console.warn("[RCA] 无 hostConfig,跳过 SSH 诊断");
+ }
+
+ // 6. Phase 2 — AI 综合根因分析
+ try {
+ const phase2Input = JSON.stringify({
+ alert: alertInfo,
+ metrics,
+ catalog_id: catalogId,
+ command_outputs: allCmdOutputs.slice(0, 8000),
+ });
+ const phase2Text = await _callAiForRCA(providerConfigs, PHASE2_SYSTEM, phase2Input, 2000);
+ const phase2Json = _extractJson(phase2Text);
+
+ const rcaPayload = JSON.stringify({
+ __rca_json: true,
+ generated_at: Date.now(),
+ source: "ai_full",
+ summary: phase2Json.summary,
+ confidence: phase2Json.confidence,
+ rootCauses: phase2Json.rootCauses,
+ evidence,
+ recommendations: phase2Json.recommendations,
+ });
+
+ // 7. 写回告警 note
+ try {
+ resetCaches();
+ const rawAlerts = readFn("netcatty_inspection_alert_records_v1");
+ let alertArr = typeof rawAlerts === "string" ? JSON.parse(rawAlerts || "[]") : (rawAlerts || []);
+ alertArr = alertArr.map(a => a.id === alertId ? { ...a, note: "[RCA_JSON]" + rcaPayload } : a);
+ writeFn("netcatty_inspection_alert_records_v1", JSON.stringify(alertArr));
+ console.log("[RCA] 结果已写回告警 note");
+ } catch (e) {
+ console.error("[RCA] 写回告警失败:", e.message);
+ }
+
+ return {
+ success: true,
+ rca: {
+ alertId,
+ deviceId: alert.device_id,
+ deviceName: alert.device_name,
+ confidence: phase2Json.confidence ?? 0,
+ rootCauses: phase2Json.rootCauses || [],
+ evidence,
+ recommendations: phase2Json.recommendations || [],
+ summary: phase2Json.summary || "",
+ },
+ source: "ai",
+ durationMs: Date.now() - start,
+ toolInvocations: results.toolInvocations,
+ };
+ } catch (e) {
+ console.error("[RCA] Phase 2 综合失败:", e.message);
+ return {
+ success: false,
+ error: "Phase 2 AI 综合失败: " + e.message,
+ rca: null,
+ durationMs: Date.now() - start,
+ toolInvocations: results.toolInvocations,
+ };
+ }
+ }
+
+ // 注册为 IPC handler(Electron 本地备用,主进程直接调用)
+ ipcMain.handle("inspection:runAlertRCA", async (_e, alertId) => {
+ try {
+ const result = await runAlertRCA(alertId);
+ return { ok: result.success, ...result };
+ } catch (e) {
+ return { ok: false, error: e?.message || String(e) };
+ }
+ });
+
+ // 导出给 remoteHttpServer 直接调用(同一个主进程,共享 _hostConfigs 和存储)
+ module.exports.runAlertRCA = runAlertRCA;
+
// ---------- 巡检报告快照 ----------
// runReportSnapshot: renderer 侧解密 vault 构建 hostConfig,传入主进程执行报告命令组
// payload: {
@@ -872,6 +1219,7 @@ function registerInspectionHandlers(ipcMain, options) {
// 内部函数:执行一次快照(IPC handler + reportScheduler 共用)
async function _executeSnapshot(deviceTargets, trigger = "manual") {
const t0 = Date.now();
+ const DEBUG = process.env.INSPECTION_DEBUG === "1";
// 打印唯一调用栈前几帧 —— 一眼看出是谁调的
const stack = new Error().stack?.split("\n").slice(2, 5).join("\n ") || "";
console.error(`[EXEC_SNAPSHOT] ════════════════════════════════════`);
@@ -1033,7 +1381,7 @@ function registerInspectionHandlers(ipcMain, options) {
// 自动触发 AI 分析 —— 不阻塞快照返回,后台独立执行
const bridge = require("../aiBridge.cjs");
- const activeProviders = bridge.aiBridgeContext?.providerConfigs || [];
+ const activeProviders = bridge.providerConfigs || [];
const defaultProvider = activeProviders.find(p => p?.selected) || activeProviders[0];
const defaultProviderId = defaultProvider?.id;
setImmediate(() => {
@@ -1047,40 +1395,66 @@ function registerInspectionHandlers(ipcMain, options) {
/** AI 内部执行函数 —— 自动触发和 IPC handler 共用同一套逻辑 */
async function _runReportAiSummaryInternal(snapshotId, providerId) {
+ console.log("[ReportAiSummaryInternal] 🚀 开始执行 snapshotId=", snapshotId, "providerId=", providerId);
const snapshot = reportStore.getSnapshot(readFn, snapshotId);
- if (!snapshot) return;
+ if (!snapshot) {
+ console.log("[ReportAiSummaryInternal] ❌ 快照不存在:", snapshotId);
+ return;
+ }
+ console.log("[ReportAiSummaryInternal] ✅ 快照找到, devices=", snapshot.devices?.length || 0);
reportStore.setSnapshotAiStatus(readFn, writeFn, snapshotId, "running");
_broadcastReportAiStatus(snapshotId, "running");
try {
const bridge = require("../aiBridge.cjs");
- const activeProviders = bridge.aiBridgeContext?.providerConfigs || [];
+ const activeProviders = bridge.providerConfigs;
+ console.log("[ReportAiSummaryInternal] activeProviders=", activeProviders.length);
const provider = activeProviders.find(p => p.id === providerId)
|| activeProviders.find(p => p.enabled !== false);
if (!provider) throw new Error("AI provider 未配置");
- const resolved = bridge.aiBridgeContext.resolveProviderApiKey(provider.id);
+ console.log("[ReportAiSummaryInternal] ✅ provider=", provider.id, provider.name);
+ const resolved = bridge.resolveProviderApiKey(provider.id);
if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
+ console.log("[ReportAiSummaryInternal] ✅ API key 已解密, 长度=", resolved.apiKey.length);
const userContent = _buildSnapshotPrompt(snapshot);
+ // 兜底:如果压缩后仍超过 50KB,硬截断设备数量
+ let finalContent = userContent;
+ if (userContent.length > 50000) {
+ try {
+ const parsed = JSON.parse(userContent);
+ if (parsed.online && parsed.online.length > 12) {
+ parsed.online = parsed.online.slice(0, 12);
+ parsed.summary.online_n = 12;
+ finalContent = JSON.stringify(parsed);
+ }
+ } catch {}
+ }
const url = _buildAiUrl(provider);
const headers = { "Content-Type": "application/json", "Authorization": "Bearer __IPC_SECURED__" };
- const body = JSON.stringify(_buildAiBody(provider, userContent));
- const patched = bridge.aiBridgeContext.injectApiKeyIntoRequest(url, headers, provider.id);
- const skipTLS = bridge.aiBridgeContext.shouldSkipTLSVerify(provider.id);
+ const body = JSON.stringify(_buildAiBody(provider, finalContent));
+ console.log("[ReportAiSummaryInternal] 准备请求 AI, url=", url?.slice(0, 80), "body.length=", body.length);
+ const patched = bridge.injectApiKeyIntoRequest(url, headers, provider.id);
+ const skipTLS = bridge.shouldSkipTLSVerify(provider.id);
+ console.log("[ReportAiSummaryInternal] 发送 HTTP 请求...");
const result = await _doHttpFetch(patched.url, patched.headers, body, { skipTLS, timeoutMs: 60000 });
+ console.log("[ReportAiSummaryInternal] HTTP 返回 status=", result.status, "ok=", result.ok, "error=", result.error);
if (!result.ok) throw new Error(result.error || `HTTP ${result.status}`);
const raw = _parseAiResponse(provider, result.data);
+ console.log("[ReportAiSummaryInternal] 解析 AI 响应长度=", raw?.length || 0);
const parsed = _extractJson(raw);
const summary = parsed || { health_summary: raw, offline_devices: [], hidden_risks: [], recommendations: [] };
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, summary);
_broadcastReportAiStatus(snapshotId, "done");
+ console.log("[ReportAiSummaryInternal] ✅ AI 分析完成!");
} catch (err) {
const msg = err?.message || String(err);
console.error("[runReportAiSummary] 失败:", msg);
+ console.error("[runReportAiSummary] stack:", err?.stack?.split('\n').slice(0, 5).join('\n'));
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, null, msg);
_broadcastReportAiStatus(snapshotId, "failed", msg);
}
@@ -1141,6 +1515,189 @@ function registerInspectionHandlers(ipcMain, options) {
return { ok };
});
+ // 拓扑位置持久化(Electron 拖拽保存 → 远程浏览器可同步读取)
+ ipcMain.handle("inspection:saveTopologyPositions", (_e, positions) => {
+ if (!positions || typeof positions !== "object") return { ok: false };
+ try {
+ if (Object.keys(positions).length === 0) {
+ const existing = readStorage("_topology_positions");
+ if (existing && typeof existing === "object" && Object.keys(existing).length > 0) {
+ console.info("[inspection] saveTopologyPositions skip empty");
+ return { ok: true, skipped: true };
+ }
+ }
+ writeStorage("_topology_positions", positions);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: e?.message };
+ }
+ });
+
+ ipcMain.handle("inspection:getTopologyPositions", () => {
+ try {
+ return readStorage("_topology_positions") || {};
+ } catch { return {}; }
+ });
+
+ // 监控大屏标题同步(Electron localStorage → 远程浏览器可读)
+ ipcMain.handle("inspection:saveDashboardTitle", (_e, title) => {
+ if (!title || typeof title !== "string") return { ok: false };
+ try {
+ const RENDERER_DEFAULT = "云启Net智能巡检监测系统";
+ const existing = readStorage("_dashboard_title");
+ if (title === RENDERER_DEFAULT && existing && existing !== RENDERER_DEFAULT) {
+ console.info("[inspection] saveDashboardTitle skip default:", title, "vs:", existing);
+ return { ok: true, skipped: true };
+ }
+ writeStorage("_dashboard_title", title);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: e?.message };
+ }
+ });
+
+ // 产品名称同步(侧边栏显示名,远程浏览器读)
+ ipcMain.handle("inspection:saveProductName", (_e, name) => {
+ if (!name || typeof name !== "string") return { ok: false };
+ try {
+ const RENDERER_DEFAULT = "云启智能运维平台";
+ const existing = readStorage("_product_name");
+ if (name === RENDERER_DEFAULT && existing && existing !== RENDERER_DEFAULT) {
+ console.info("[inspection] saveProductName skip default:", name, "vs:", existing);
+ return { ok: true, skipped: true };
+ }
+ writeStorage("_product_name", name);
+ return { ok: true };
+ } catch (e) { return { ok: false, error: e?.message }; }
+ });
+
+ ipcMain.handle("inspection:getProductName", () => {
+ try { return readStorage("_product_name") || ""; } catch { return ""; }
+ });
+
+ // 报告产品名同步
+ ipcMain.handle("inspection:saveReportProductName", (_e, name) => {
+ if (typeof name !== "string") return { ok: false };
+ try {
+ if ((name === "" || name === null) && name !== undefined) {
+ const existing = readStorage("_report_product_name");
+ if (existing && existing.length > 0) {
+ console.info("[inspection] saveReportProductName skip empty, existing:", existing);
+ return { ok: true, skipped: true };
+ }
+ }
+ writeStorage("_report_product_name", name);
+ return { ok: true };
+ } catch (e) { return { ok: false, error: e?.message }; }
+ });
+
+ ipcMain.handle("inspection:getReportProductName", () => {
+ try { return readStorage("_report_product_name") || ""; } catch { return ""; }
+ });
+
+ // 完整 vault hosts 同步(前端加载后发给主进程,远程浏览器 API 读)
+ ipcMain.handle("inspection:saveVaultHosts", (_e, hosts) => {
+ if (!Array.isArray(hosts)) { console.info("[inspection] saveVaultHosts called but not array:", typeof hosts); return { ok: false }; }
+ try {
+ console.info("[inspection] saveVaultHosts called, count:", hosts.length, "sample keys:", hosts[0] ? Object.keys(hosts[0]).join(',') : 'empty');
+ // 守卫:renderer 首次启动 Local Storage 空时会传 [],
+ // 但 inspectionStorage 可能已有完整数据(含凭证)→ 不要覆盖!
+ if (hosts.length === 0) {
+ const existing = readStorage("_vault_hosts");
+ if (Array.isArray(existing) && existing.length > 0) {
+ console.info("[inspection] saveVaultHosts 跳过空覆盖");
+ return { ok: true, count: existing.length, skipped: true };
+ }
+ }
+ // 保存完整 hosts(含 username/password/port/identityFileId 等凭证字段)
+ const mapped = hosts.map(h => ({
+ id: h.id,
+ label: h.label || h.name || h.hostname || "",
+ hostname: h.hostname || h.name || "",
+ port: h.port,
+ username: h.username,
+ password: h.password,
+ identityFileId: h.identityFileId,
+ privateKey: h.privateKey,
+ passphrase: h.passphrase,
+ type: h.type || "",
+ distro: h.distro || h.catalogId || "",
+ group: h.group || "",
+ }));
+ writeStorage("_vault_hosts", mapped);
+ return { ok: true, count: mapped.length };
+ } catch (e) {
+ return { ok: false, error: e?.message };
+ }
+ });
+
+ ipcMain.handle("inspection:getVaultHosts", () => {
+ try {
+ return readStorage("_vault_hosts") || [];
+ } catch { return []; }
+ });
+
+ // Vault 通用数据同步(renderer localStorage → 主进程 inspection-data.json → 远程 HTTP API 读)
+ // 覆盖所有子页面:钥匙串/代理/端口转发/脚本/笔记/已知主机/日志/分组/身份
+ const _VAULT_DATA_KEYS = ["hosts", "keys", "proxies", "port_forwards", "snippets", "notes", "known_hosts", "connection_logs", "groups", "identities"];
+ _VAULT_DATA_KEYS.forEach(vk => {
+ const sk = "_vault_" + vk;
+ // renderer → 主进程
+ // 守卫:renderer 首次启动 Local Storage 空时会传 [],
+ // 但 inspectionStorage 可能已有从开发版拷过来的完整数据 → 不要覆盖!
+ ipcMain.handle("inspection:syncVault:" + vk, (_e, data) => {
+ try {
+ const incomingEmpty = data === null || data === undefined
+ || (Array.isArray(data) && data.length === 0)
+ || (typeof data === "object" && !Array.isArray(data) && Object.keys(data).length === 0);
+ const existing = readStorage(sk);
+ const existingNonEmpty = existing !== null && existing !== undefined
+ && ((Array.isArray(existing) && existing.length > 0)
+ || (typeof existing === "object" && !Array.isArray(existing) && Object.keys(existing).length > 0));
+ if (incomingEmpty && existingNonEmpty) {
+ console.info(`[inspection] syncVault:${vk} 跳过:renderer 传空,但 inspectionStorage 已有 ${(Array.isArray(existing) ? existing.length : Object.keys(existing).length)} 条`);
+ return { ok: true, skipped: true };
+ }
+ writeStorage(sk, data ?? (Array.isArray(data) ? [] : {}));
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: e?.message };
+ }
+ });
+ // 主进程内部读取(remoteHttpServer 也会直接 require inspectionStorage 读)
+ ipcMain.handle("inspection:getVault:" + vk, () => {
+ try { return readStorage(sk); } catch { return null; }
+ });
+ });
+
+ // 拓扑连线同步(Electron localStorage → 主进程 → 远程浏览器 API 读)
+ ipcMain.handle("inspection:getTopologyLinks", () => {
+ try { const v = readStorage("_topology_links"); return Array.isArray(v) ? v : []; } catch { return []; }
+ });
+
+ ipcMain.handle("inspection:saveTopologyLinks", (_e, links) => {
+ try {
+ const arr = Array.isArray(links) ? links : [];
+ if (arr.length === 0) {
+ const existing = readStorage("_topology_links");
+ if (Array.isArray(existing) && existing.length > 0) {
+ console.info("[inspection] saveTopologyLinks skip empty");
+ return { ok: true, count: existing.length, skipped: true };
+ }
+ }
+ writeStorage("_topology_links", arr);
+ return { ok: true, count: arr.length };
+ } catch (e) {
+ return { ok: false, error: e?.message };
+ }
+ });
+
+ ipcMain.handle("inspection:getDashboardTitle", () => {
+ try {
+ return readStorage("_dashboard_title") || "";
+ } catch { return ""; }
+ });
+
ipcMain.handle("inspection:clearReportAiSummary", (_e, { snapshotId }) => {
if (!snapshotId) return { ok: false, error: "snapshotId required" };
const ok = reportStore.clearSnapshotAiSummary(readFn, writeFn, snapshotId);
@@ -1150,8 +1707,12 @@ function registerInspectionHandlers(ipcMain, options) {
ipcMain.handle("inspection:runReportAiSummary", async (_e, { snapshotId, providerId }) => {
if (!snapshotId) return { ok: false, error: "snapshotId required" };
+ console.log("[ReportAiSummary] ⚡ 收到 IPC 请求 snapshotId=", snapshotId, "providerId=", providerId);
+ console.log("[ReportAiSummary] providerConfigs=", require("../aiBridge.cjs").aiBridgeContext?.providerConfigs?.length || 0, "个");
+
// 后台独立执行 —— 不阻塞 IPC 返回
setImmediate(() => {
+ console.log("[ReportAiSummary] ⏱️ setImmediate 触发执行...");
_runReportAiSummaryInternal(snapshotId, providerId)
.catch(err => console.error("[runReportAiSummary] 异常:", err?.message || String(err)));
});
@@ -1360,6 +1921,7 @@ function registerInspectionHandlers(ipcMain, options) {
// 存储前端传入的设备 SSH 配置(hostId → sshConfig)
let _hostConfigs = null;
+ let _loggedHostCreds = false;
// ---------- 调度器控制 ----------
ipcMain.handle("inspection:scheduler:start", (_e, options = {}) => {
@@ -1379,21 +1941,35 @@ function registerInspectionHandlers(ipcMain, options) {
}
}
+ // 持久化 hostInfo 到 inspection-data.json,供远程 HTTP Server 读取补全设备名
+ try {
+ writeFn("_host_info_cache", hostInfo);
+ } catch {}
+
if (!_scheduler) {
_scheduler = new InspectionScheduler({
readFn,
writeFn,
execFn: async (hostId, command) => {
- // 优先用前端传入的 hostConfigs
if (_hostConfigs && _hostConfigs[hostId]) {
const config = _hostConfigs[hostId];
+ if (!_loggedHostCreds) {
+ console.log("[Inspection] hostConfigs 凭据快照:", JSON.stringify(
+ Object.fromEntries(Object.entries(_hostConfigs).map(([id, c]) => [id, {
+ host: c.host,
+ username: c.username,
+ hasPassword: !!c.password,
+ hasPrivateKey: !!c.privateKey,
+ }]))
+ ));
+ _loggedHostCreds = true;
+ }
const { execSshCommand: execCmd } = require("./inspectionExec.cjs");
return await execCmd(config, command, {
connectTimeoutMs: 20000,
execTimeoutMs: 30000,
});
}
- // 兜底:尝试复用已有终端会话
const session = findSessionByHost(hostId);
if (session?.conn) {
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
@@ -1403,14 +1979,56 @@ function registerInspectionHandlers(ipcMain, options) {
}
throw new Error(`无法执行巡检:未找到主机 ${hostId} 的 SSH 配置`);
},
+ // 批量执行多条命令(一次连接执行多条,华为/H3C 交换机采集器需要)
+ // 闭包和 execFn 一样——动态读 _hostConfigs 最新值
+ execCommandsFn: async (hostId, commands, options = {}) => {
+ if (_hostConfigs && _hostConfigs[hostId]) {
+ const config = _hostConfigs[hostId];
+ if (!_loggedHostCreds) {
+ console.log("[Inspection] execCommandsFn 凭据快照:", JSON.stringify(
+ Object.fromEntries(Object.entries(_hostConfigs).map(([id, c]) => [id, {
+ host: c.host,
+ username: c.username,
+ hasPassword: !!c.password,
+ hasPrivateKey: !!c.privateKey,
+ }]))
+ ));
+ _loggedHostCreds = true;
+ }
+ const { execSshCommands } = require("./inspectionExec.cjs");
+ return await execSshCommands(config, commands, {
+ connectTimeoutMs: 20000,
+ execTimeoutMs: options.execTimeoutMs || 30000,
+ ...options,
+ });
+ }
+ throw new Error(`无法批量执行巡检:未找到主机 ${hostId} 的 SSH 配置`);
+ },
pollIntervalMs: 30 * 1000,
concurrency: 5,
timeoutMs: 30 * 1000,
hostInfo,
onAlertChanged: broadcastAlertChanged,
});
+ } else {
+ // scheduler 已存在:更新 hostInfo(告警显示 device_name 用)
+ _scheduler._hostInfo = hostInfo;
+ console.log("[Inspection] 更新 hostConfigs:", Object.keys(_hostConfigs || {}).length, "台设备");
}
+ // start() 幂等:已 running 时直接返回;这里调 scanAllNow 让新 hostConfigs 立即生效,
+ // 不用等下一个 30 秒 tick
+ const wasRunning = _scheduler.isRunning();
_scheduler.start();
+ if (wasRunning && Object.keys(_hostConfigs || {}).length > 0) {
+ // 已在跑 → 立即触发一次 scanAllNow,把新 hostConfigs 对应的设备加入队列
+ setImmediate(() => {
+ try {
+ _scheduler.scanAllNow();
+ } catch (e) {
+ console.log("[Inspection] 触发立即巡检失败:", e.message);
+ }
+ });
+ }
return _scheduler.getStatus();
});
@@ -1426,6 +2044,23 @@ function registerInspectionHandlers(ipcMain, options) {
return _scheduler ? _scheduler.getStatus() : { running: false };
});
+ // ─── 全局自动巡检一键启用/停止 ───
+ ipcMain.handle("inspection:scheduler:getAutoEnabled", () => {
+ const raw = readFn("inspection-auto-enabled");
+ return raw !== "false"; // 默认 true
+ });
+
+ ipcMain.handle("inspection:scheduler:setAutoEnabled", (_e, enabled) => {
+ const value = !!enabled;
+ writeFn("inspection-auto-enabled", value ? "true" : "false");
+ console.log(`[InspectionScheduler] 全局自动巡检 ${value ? "已启用" : "已停止"}`);
+ if (!value && _scheduler) {
+ // 停止时清空队列,避免积压
+ _scheduler._scanQueue = [];
+ }
+ return { enabled: value };
+ });
+
ipcMain.handle("inspection:scheduler:scanNow", (_e, hostId) => {
if (!_scheduler) return { error: "调度器未启动" };
return _scheduler.scanDeviceNow(hostId);
diff --git a/electron/bridges/inspection/inspectionResultStore.cjs b/electron/bridges/inspection/inspectionResultStore.cjs
index 564c8ed..82f02c8 100644
--- a/electron/bridges/inspection/inspectionResultStore.cjs
+++ b/electron/bridges/inspection/inspectionResultStore.cjs
@@ -1,4 +1,4 @@
-"use strict";
+"use strict";
/**
* 巡检结果存储
@@ -22,8 +22,10 @@ function _ensureLoaded(readFn) {
}
function _safeParse(text, def) {
+ if (text === null || text === undefined) return def;
+ if (typeof text === "object") return text;
try { return JSON.parse(text); }
- catch (e) { return def; }
+ catch { return def; }
}
function _save(writeFn) {
diff --git a/electron/bridges/inspection/inspectionScheduler.cjs b/electron/bridges/inspection/inspectionScheduler.cjs
index a1002d8..0a76b89 100644
--- a/electron/bridges/inspection/inspectionScheduler.cjs
+++ b/electron/bridges/inspection/inspectionScheduler.cjs
@@ -33,7 +33,8 @@ class InspectionScheduler {
* @param {function} options.readFn - 读取存储 (key) => string
* @param {function} options.writeFn - 写入存储 (key, value) => void
* @param {function} options.getConnectionFn - 获取 SSH 连接 (hostId) => connection 或 null
- * @param {function} options.execFn - 执行命令 (hostId, command) => Promise
+ * @param {function} options.execFn - 执行单条命令 (hostId, command) => Promise
+ * @param {function} [options.execCommandsFn] - 批量执行多条命令 (hostId, commands) => Promise<{ results, stdout }>
* @param {function} [options.onResult] - 单设备巡检完成回调
* @param {number} [options.pollIntervalMs] - 轮询间隔
* @param {number} [options.concurrency] - 并发数
@@ -44,6 +45,7 @@ class InspectionScheduler {
this.writeFn = options.writeFn;
this.getConnectionFn = options.getConnectionFn;
this.execFn = options.execFn;
+ this.execCommandsFn = options.execCommandsFn;
this.onResult = options.onResult || (() => {});
this.onAlertChanged = options.onAlertChanged || (() => {});
this.pollIntervalMs = options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
@@ -93,6 +95,13 @@ class InspectionScheduler {
if (!this._running) return;
try {
+ // 全局自动巡检开关(一键启用/停止)
+ const autoEnabledRaw = this.readFn("inspection-auto-enabled");
+ const autoEnabled = autoEnabledRaw !== "false"; // 默认 true
+ if (!autoEnabled) {
+ return; // 全局停止 —— 跳过本次 tick
+ }
+
// 1. 获取所有启用巡检的设备
const devices = deviceStore.getEnabledDevices(this.readFn);
@@ -149,6 +158,8 @@ class InspectionScheduler {
}
// 2. 创建采集器
+ // connectionInfo 只传 { host: hostId } 作为标识——adapter 的 execCommands 会被 options.execCommands 覆盖,
+ // 真正的 SSH 凭据由主进程 execCommandsFn 从 _hostConfigs 读取(和 execFn 同理)
const adapter = adapterRegistry.createAdapter(
catalog.adapter_class,
{ host: hostId },
@@ -157,6 +168,11 @@ class InspectionScheduler {
if (!this.execFn) throw new Error("execFn 未配置");
return this.execFn(hostId, cmd);
},
+ execCommands: this.execCommandsFn
+ ? async (commands, options) => {
+ return this.execCommandsFn(hostId, commands, options);
+ }
+ : undefined,
timeoutMs: this.timeoutMs,
}
);
@@ -281,10 +297,8 @@ class InspectionScheduler {
console.log(`[InspectionScheduler] 告警评估 device=${deviceName}(${deviceExt.host_id}) online=${metrics.online} 规则数=${rules.length}`);
for (const rule of rules) {
- // 特殊处理:online 指标
if (rule.metric === "online") {
const isOnline = metrics.online !== false;
- // threshold 为 "false" 表示离线时告警
const shouldAlert = rule.threshold === "false" ? !isOnline : isOnline;
console.log(`[InspectionScheduler] 离线规则: name=${rule.name} enabled=${rule.enabled} threshold=${rule.threshold} isOnline=${isOnline} shouldAlert=${shouldAlert}`);
const alertResult = alertRecordStore.processAlert(
@@ -308,8 +322,8 @@ class InspectionScheduler {
const isTriggered = alertRuleStore.evaluateRule(value, rule.operator, rule.threshold);
- const hostInfo = this._hostInfo[deviceExt.host_id] || {};
- const deviceName = hostInfo.label || hostInfo.hostname || deviceExt.host_id;
+ // 注意:hostInfo / deviceName 在函数顶部 L285-286 已声明,此处直接复用,
+ // 避免同名 const 重复声明导致 TDZ(Cannot access 'deviceName' before initialization)
const alertResult = alertRecordStore.processAlert(
this.readFn,
this.writeFn,
diff --git a/electron/bridges/inspection/inspectionStorage.cjs b/electron/bridges/inspection/inspectionStorage.cjs
index faa72af..ba6d986 100644
--- a/electron/bridges/inspection/inspectionStorage.cjs
+++ b/electron/bridges/inspection/inspectionStorage.cjs
@@ -81,9 +81,17 @@ function flushSync() {
_flushToDisk();
}
+function resetCache() {
+ // 先确保内存中的修改已写到磁盘(200ms 异步保存可能还没触发)
+ _flushToDisk();
+ _storageCache = null;
+ _loadFromDisk();
+}
+
module.exports = {
init,
readStorage,
writeStorage,
flushSync,
+ resetCache,
};
diff --git a/electron/bridges/inspection/reportScheduleStore.cjs b/electron/bridges/inspection/reportScheduleStore.cjs
index 983a6c8..7aed331 100644
--- a/electron/bridges/inspection/reportScheduleStore.cjs
+++ b/electron/bridges/inspection/reportScheduleStore.cjs
@@ -1,4 +1,4 @@
-"use strict";
+"use strict";
/**
* 巡检报告调度配置存储
@@ -26,6 +26,8 @@ const DEFAULTS = {
};
function _safeParse(raw, def) {
+ if (raw === null || raw === undefined) return def;
+ if (typeof raw === "object") return raw;
try { return JSON.parse(raw); }
catch { return def; }
}
diff --git a/electron/bridges/inspection/reportScheduler.cjs b/electron/bridges/inspection/reportScheduler.cjs
index 1ab8c86..7d509a7 100644
--- a/electron/bridges/inspection/reportScheduler.cjs
+++ b/electron/bridges/inspection/reportScheduler.cjs
@@ -90,7 +90,8 @@ function start({ readStorage, writeStorage, runSnapshotFn, onDailyReportReady, g
};
// 启动后等 60s tick 自动处理第一次(不立即触发,避免重启爆发)
- console.log("[reportScheduler] 已启动,间隔", (cfg.defaultIntervalHours || 1), "h,下次 tick 检查在 60s 后");
+ const curCfg = reportScheduleStore.getSchedule(readFn);
+ console.log("[reportScheduler] 已启动,间隔", (curCfg.defaultIntervalHours || 1), "h,下次 tick 检查在 60s 后");
// 每 60 秒 tick 一次
_tickTimer = setInterval(() => { tick().catch(e => console.error("[reportScheduler] tick 异常:", e)); }, 60 * 1000);
diff --git a/electron/bridges/inspection/reportStore.cjs b/electron/bridges/inspection/reportStore.cjs
index dfa917b..0657547 100644
--- a/electron/bridges/inspection/reportStore.cjs
+++ b/electron/bridges/inspection/reportStore.cjs
@@ -1,4 +1,4 @@
-"use strict";
+"use strict";
/**
* 巡检报告快照存储
@@ -35,8 +35,10 @@ const MAX_CMD_STDOUT = 4000; // 单条命令 stdout 保留前 4000 字符
let _snapshots = null;
function _safeParse(text, def) {
+ if (text === null || text === undefined) return def;
+ if (typeof text === "object") return text;
try { return JSON.parse(text); }
- catch (e) { return def; }
+ catch { return def; }
}
function _ensureLoaded(readFn) {
@@ -215,6 +217,8 @@ function clearSnapshotAiSummary(readFn, writeFn, snapshotId) {
return true;
}
+function _resetForTest() { _snapshots = null; }
+
module.exports = {
MAX_SNAPSHOTS,
MAX_CMD_STDOUT,
@@ -227,4 +231,5 @@ module.exports = {
setSnapshotAiStatus,
updateSnapshotAiSummary,
clearSnapshotAiSummary,
+ _resetForTest,
};
diff --git a/electron/main.cjs b/electron/main.cjs
index 9ce964d..6c6ee9e 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -268,10 +268,10 @@ const portableData = applyPortableDataDirectory({ app });
if (portableData) {
console.info(`[Main] Portable data directory: ${portableData.dataDirectory}`);
}
+const appPath = path.join(__dirname, "..");
const preload = path.join(__dirname, "preload.cjs");
const isMac = process.platform === "darwin";
const appIconManager = require("./bridges/appIconManager.cjs");
-const appPath = path.join(__dirname, "..");
appIconManager.initializeAppIconManager(appPath, {
preferPublic: !app.isPackaged,
isMac,
@@ -1313,6 +1313,7 @@ if (!gotLock) {
// Application lifecycle
app.whenReady().then(async () => {
registerAppProtocol();
+
const initialSshDeepLinkPreference = applyInitialSshDeepLinkPreference({
enabled: sshDeepLinkEnabled,
applyPreference: (enabled) => applySshProtocolClientPreference({ app, enabled, isDev }),
diff --git a/electron/main/registerBridges.cjs b/electron/main/registerBridges.cjs
index 89cc2ec..d54a8b1 100644
--- a/electron/main/registerBridges.cjs
+++ b/electron/main/registerBridges.cjs
@@ -1196,6 +1196,21 @@ function createBridgeRegistrar(context) {
});
console.log('[Main] All bridges registered successfully');
+
+ // ─── 远程 HTTP Server(单机版浏览器访问) ───
+ try {
+ const remoteHttp = require("../remoteHttpServer.cjs");
+ // 复用已初始化的 inspectionStorage,共享同一个模块级缓存
+ // 这样 Electron 本地改配置后,远程 HTTP API 立即读到最新值
+ ipcMain.handle("remoteHttp:getStatus", () => remoteHttp.getStatus());
+ ipcMain.handle("remoteHttp:configure", (_e, enabled, port) => remoteHttp.configure(enabled, port));
+ // 自动启动(打包版才有意义,开发版也启方便调试)
+ remoteHttp.start({ storage: inspectionStorage }).catch((e) => {
+ console.warn("[RemoteHTTP] auto-start failed:", e.message);
+ });
+ } catch (e) {
+ console.warn("[RemoteHTTP] 远程 HTTP Server 初始化失败:", e.message);
+ }
};
return registerBridges;
diff --git a/electron/portableData.cjs b/electron/portableData.cjs
index 21f05ca..a02a6f1 100644
--- a/electron/portableData.cjs
+++ b/electron/portableData.cjs
@@ -6,25 +6,30 @@ function applyPortableDataDirectory({
env = process.env,
platform = process.platform,
}) {
- if (platform !== "win32" || !app?.isPackaged) return null;
+ // 触发条件:exe 所在目录下有 .portable 标记文件
+ // 这样开发版(没有 .portable)完全不受影响,只有绿色便携版才生效
+ if (platform !== "win32") return null;
- const portableLauncherDirectory = typeof env?.PORTABLE_EXECUTABLE_DIR === "string"
- ? env.PORTABLE_EXECUTABLE_DIR.trim()
- : "";
- const baseDirectory = portableLauncherDirectory || path.dirname(app.getPath("exe"));
- const dataDirectory = path.join(baseDirectory, "data");
- if (!fs.existsSync(dataDirectory) || !fs.statSync(dataDirectory).isDirectory()) {
+ let exeDir;
+ try { exeDir = path.dirname(app.getPath("exe")); } catch { return null; }
+
+ const markerFile = path.join(exeDir, ".portable");
+ if (!fs.existsSync(markerFile)) return null;
+
+ const dataDirectory = path.join(exeDir, "data");
+
+ try {
+ if (!fs.existsSync(dataDirectory)) {
+ fs.mkdirSync(dataDirectory, { recursive: true });
+ }
+ } catch (_) {
return null;
}
+ console.info(`[Main] Portable data directory (via .portable marker): ${dataDirectory}`);
app.setPath("userData", dataDirectory);
app.setPath("sessionData", dataDirectory);
- return {
- dataDirectory,
- source: portableLauncherDirectory
- ? "portable-launcher-directory"
- : "executable-directory",
- };
+ return { dataDirectory, source: "marker-file" };
}
module.exports = { applyPortableDataDirectory };
diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs
index 1c96b1e..d6762fd 100644
--- a/electron/preload/api.cjs
+++ b/electron/preload/api.cjs
@@ -2081,6 +2081,7 @@ function createPreloadApi(ctx) {
getAllExtensions: () => ipcRenderer.invoke("inspection:getAllExtensions"),
getExtension: (hostId) => ipcRenderer.invoke("inspection:getExtension", hostId),
upsertExtension: (hostId, data) => ipcRenderer.invoke("inspection:upsertExtension", hostId, data),
+ batchSetInspectionEnabled: (enabled) => ipcRenderer.invoke("inspection:batchSetInspectionEnabled", enabled),
deleteExtension: (hostId) => ipcRenderer.invoke("inspection:deleteExtension", hostId),
getEffectiveInterval: (hostId) => ipcRenderer.invoke("inspection:getEffectiveInterval", hostId),
getEnabledDevices: () => ipcRenderer.invoke("inspection:getEnabledDevices"),
@@ -2105,6 +2106,8 @@ function createPreloadApi(ctx) {
schedulerStatus: () => ipcRenderer.invoke("inspection:scheduler:status"),
scanNow: (hostId) => ipcRenderer.invoke("inspection:scheduler:scanNow", hostId),
scanAllNow: () => ipcRenderer.invoke("inspection:scheduler:scanAllNow"),
+ getAutoEnabled: () => ipcRenderer.invoke("inspection:scheduler:getAutoEnabled"),
+ setAutoEnabled: (enabled) => ipcRenderer.invoke("inspection:scheduler:setAutoEnabled", enabled),
runInspection: (payload) => ipcRenderer.invoke("inspection:runInspection", payload),
runSingleCommand: (payload) => ipcRenderer.invoke("inspection:runSingleCommand", payload),
// 巡检报告快照
@@ -2116,6 +2119,23 @@ function createPreloadApi(ctx) {
saveReportAiSummary: (snapshotId, aiSummary) => ipcRenderer.invoke("inspection:saveReportAiSummary", { snapshotId, aiSummary }),
clearReportAiSummary: (snapshotId) => ipcRenderer.invoke("inspection:clearReportAiSummary", { snapshotId }),
runReportAiSummary: (snapshotId, providerId) => ipcRenderer.invoke("inspection:runReportAiSummary", { snapshotId, providerId }),
+ // 拓扑位置持久化(供远程浏览器同步读取)
+ saveTopologyPositions: (positions) => ipcRenderer.invoke("inspection:saveTopologyPositions", positions),
+ getTopologyPositions: () => ipcRenderer.invoke("inspection:getTopologyPositions"),
+ // 监控大屏标题同步
+ saveDashboardTitle: (title) => ipcRenderer.invoke("inspection:saveDashboardTitle", title),
+ getDashboardTitle: () => ipcRenderer.invoke("inspection:getDashboardTitle"),
+ saveProductName: (name) => ipcRenderer.invoke("inspection:saveProductName", name),
+ getProductName: () => ipcRenderer.invoke("inspection:getProductName"),
+ saveReportProductName: (name) => ipcRenderer.invoke("inspection:saveReportProductName", name),
+ getReportProductName: () => ipcRenderer.invoke("inspection:getReportProductName"),
+ saveVaultHosts: (hosts) => ipcRenderer.invoke("inspection:saveVaultHosts", hosts),
+ getVaultHosts: () => ipcRenderer.invoke("inspection:getVaultHosts"),
+ // Vault 通用数据同步(keys/proxies/port_forwards/snippets/notes/known_hosts/connection_logs/groups/identities)
+ syncVaultData: (key, data) => ipcRenderer.invoke("inspection:syncVault:" + key, data),
+ getVaultData: (key) => ipcRenderer.invoke("inspection:getVault:" + key),
+ saveTopologyLinks: (links) => ipcRenderer.invoke("inspection:saveTopologyLinks", links),
+ getTopologyLinks: () => ipcRenderer.invoke("inspection:getTopologyLinks"),
// 巡检报告调度配置
getReportSchedule: () => ipcRenderer.invoke("inspection:getReportSchedule"),
saveReportSchedule: (config) => ipcRenderer.invoke("inspection:saveReportSchedule", config),
@@ -2143,6 +2163,12 @@ function createPreloadApi(ctx) {
return () => ipcRenderer.removeListener("inspection:ruleChanged", handler);
},
},
+
+ // ─── 远程 HTTP Server(浏览器访问单机版) ───
+ remoteHttp: {
+ getStatus: () => ipcRenderer.invoke("remoteHttp:getStatus"),
+ configure: (enabled, port) => ipcRenderer.invoke("remoteHttp:configure", enabled, port),
+ },
};
}
}
diff --git a/electron/remoteHttpServer.cjs b/electron/remoteHttpServer.cjs
new file mode 100644
index 0000000..2182f03
--- /dev/null
+++ b/electron/remoteHttpServer.cjs
@@ -0,0 +1,1082 @@
+"use strict";
+
+/**
+ * NetMesh 远程 HTTP Server —— 单机版浏览器可通过 URL 访问完整运行监控页面
+ *
+ * 零外部依赖,用 Node 内置 http 模块。
+ * 启动条件:storage 里 "netcatty_remote_access" 的 enabled === true。
+ *
+ * 安全模型:
+ * - 启动时在存储里生成随机 token(24 位 hex)
+ * - 访问入口(/、/index.html)和 /api/* 都要求 URL query 带 ?token=xxx
+ * - token 通过注入 script 传到 window.__REMOTE_TOKEN__,让 SPA 内 fetch 自动带上
+ */
+
+const http = require("http");
+const fs = require("fs");
+const path = require("path");
+const crypto = require("crypto");
+
+let _server = null;
+let _port = 8848;
+let _token = null;
+let _storage = null; // { readStorage, writeStorage }
+let _distDir = null; // 运行时解析的 dist 绝对路径
+
+const STORAGE_KEY = "netcatty_remote_access";
+
+// MIME 类型映射
+const MIME = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "application/javascript; charset=utf-8",
+ ".mjs": "application/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".ico": "image/x-icon",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".ttf": "font/ttf",
+ ".eot": "application/vnd.ms-fontobject",
+ ".mp3": "audio/mpeg",
+ ".mp4": "video/mp4",
+ ".wasm": "application/wasm",
+ ".map": "application/json; charset=utf-8",
+};
+
+function sendJson(res, code, data) {
+ res.writeHead(code, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-store",
+ "Access-Control-Allow-Origin": "*",
+ });
+ res.end(JSON.stringify(data));
+}
+
+function sendHtml(res, code, html) {
+ res.writeHead(code, {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-cache",
+ });
+ res.end(html);
+}
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let buf = "";
+ req.on("data", (c) => (buf += c));
+ req.on("end", () => resolve(buf));
+ req.on("error", reject);
+ });
+}
+
+function parseQuery(urlStr) {
+ const q = {};
+ const idx = urlStr.indexOf("?");
+ if (idx < 0) return { pathname: urlStr, query: q };
+ const pathname = urlStr.slice(0, idx);
+ urlStr
+ .slice(idx + 1)
+ .split("&")
+ .forEach((pair) => {
+ const [k, v] = pair.split("=");
+ if (k) q[decodeURIComponent(k)] = v ? decodeURIComponent(v) : "";
+ });
+ return { pathname, query: q };
+}
+
+// ─── dist 路径解析 ────
+function resolveDistDir() {
+ if (_distDir) return _distDir;
+ try {
+ const dist = path.join(__dirname, "../dist");
+ if (fs.existsSync(dist)) {
+ _distDir = dist;
+ return _distDir;
+ }
+ // 打包后可能在 app.asar 里
+ const dist2 = path.join(require("electron").app.getAppPath(), "dist");
+ if (fs.existsSync(dist2)) {
+ _distDir = dist2;
+ return _distDir;
+ }
+ } catch { /* 没有 electron 模块,可能在独立测试 */ }
+ // 最后 fallback:项目根 dist
+ _distDir = path.resolve(__dirname, "..", "dist");
+ return _distDir;
+}
+
+// ─── serve 静态文件 ────
+function serveStatic(res, relPath) {
+ const distDir = resolveDistDir();
+ // 安全:禁止路径穿越
+ const safeRel = relPath.replace(/^\/+/, "").replace(/\.\.\//g, "");
+ const fullPath = path.join(distDir, safeRel);
+ // 确保文件在 dist 目录内
+ const real = path.resolve(fullPath);
+ if (!real.startsWith(path.resolve(distDir))) {
+ res.writeHead(403, { "Content-Type": "text/plain" });
+ res.end("Forbidden");
+ return;
+ }
+ if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
+ res.writeHead(404, { "Content-Type": "text/plain" });
+ res.end("Not Found");
+ return;
+ }
+ const ext = path.extname(fullPath).toLowerCase();
+ res.writeHead(200, {
+ "Content-Type": MIME[ext] || "application/octet-stream",
+ "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=3600",
+ // ES module script 要求 CORS 头(同域请求也需要)
+ "Access-Control-Allow-Origin": "*",
+ });
+ fs.createReadStream(fullPath).pipe(res);
+}
+
+// ─── 内部存储模块延迟加载 ────
+function loadModules() {
+ const inspectionStorage = require("./bridges/inspection/inspectionStorage.cjs");
+ const deviceStore = require("./bridges/inspection/deviceStore.cjs");
+ const alertRecordStore = require("./bridges/inspection/alertRecordStore.cjs");
+ const inspectionResultStore = require("./bridges/inspection/inspectionResultStore.cjs");
+ const alertRuleStore = require("./bridges/inspection/alertRuleStore.cjs");
+ const reportStore = require("./bridges/inspection/reportStore.cjs");
+ const dailyReportStore = require("./bridges/inspection/dailyReportStore.cjs");
+ return { inspectionStorage, deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore, reportStore, dailyReportStore };
+}
+
+// 每次 API 请求前重置所有 store 的模块级缓存,强制从磁盘重新加载
+function resetAllCaches(modules) {
+ const m = modules || loadModules();
+ try { m.inspectionStorage.resetCache?.(); } catch {}
+ try { m.deviceStore._resetForTest?.(); } catch {}
+ try { m.alertRecordStore._resetForTest?.(); } catch {}
+ try { m.inspectionResultStore._resetForTest?.(); } catch {}
+ try { m.alertRuleStore._resetForTest?.(); } catch {}
+ try { m.reportStore._resetForTest?.(); } catch {}
+ try { m.dailyReportStore._resetForTest?.(); } catch {}
+}
+
+/**
+ * 构建 host_id → { label, hostname } 映射,多来源合并
+ * 1. 主来源:_host_info_cache(inspection scheduler start 时从 vault hostConfigs 缓存)
+ * 2. Fallback:告警记录里的 device_name(巡检执行时写入,label 优先)
+ * 3. Fallback:巡检结果里的 device_name / hostname
+ *
+ * 这个函数接收 stores 以便在 cache 未就绪时兜底。
+ */
+function buildHostLabelMap(readStorage, stores = {}) {
+ const { alertRecordStore, inspectionResultStore } = stores;
+ const map = {};
+
+ // 0) _vault_hosts(前端 Electron 同步过来的完整 vault hosts,含 label/hostname/type/distro)
+ try {
+ const raw = readStorage("_vault_hosts");
+ if (raw && Array.isArray(raw)) {
+ for (const h of raw) {
+ if (h?.id) {
+ map[h.id] = {
+ label: h.label || "",
+ hostname: h.hostname || "",
+ type: h.type || "",
+ distro: h.distro || "",
+ };
+ }
+ }
+ }
+ } catch {}
+
+ // 1) _host_info_cache(scheduler start 时构建,可能 label 空)
+ try {
+ const raw = readStorage("_host_info_cache");
+ if (raw) {
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
+ if (parsed && typeof parsed === "object") {
+ for (const [id, info] of Object.entries(parsed)) {
+ if (!map[id]) {
+ map[id] = { label: "", hostname: "" };
+ }
+ if (info?.label && !map[id].label) map[id].label = info.label;
+ if (info?.hostname && !map[id].hostname) map[id].hostname = info.hostname;
+ }
+ }
+ }
+ } catch {}
+
+ // 2) 告警记录里反查 device_id → device_name(label)
+ if (alertRecordStore) {
+ try {
+ const alerts = alertRecordStore.listAlerts(readStorage, { limit: 500 });
+ for (const a of alerts || []) {
+ if (a?.device_id && a?.device_name && !map[a.device_id]?.label) {
+ map[a.device_id] = map[a.device_id] || {};
+ map[a.device_id].label = a.device_name;
+ }
+ }
+ } catch {}
+ }
+
+ // 3) 巡检结果里反查(有些 result 也带 device_name)
+ if (inspectionResultStore) {
+ try {
+ const all = inspectionResultStore.getLatestAll(readStorage);
+ for (const r of all || []) {
+ if (!r?.device_id) continue;
+ map[r.device_id] = map[r.device_id] || {};
+ if (r.device_name && !map[r.device_id].label) {
+ map[r.device_id].label = r.device_name;
+ }
+ if (r.hostname && !map[r.device_id].hostname) {
+ map[r.device_id].hostname = r.hostname;
+ }
+ }
+ } catch {}
+ }
+
+ return map;
+}
+
+function readStorage(key) {
+ try {
+ const raw = _storage?.readStorage?.(key);
+ if (raw === null || raw === undefined) return null;
+ // 原样返回磁盘上的值:可能是 string(JSON 字符串)、对象、数组
+ // 不再额外 JSON.stringify——让下游 store 自己决定要不要 parse
+ return raw;
+ } catch {
+ return null;
+ }
+}
+
+// ─── 路由 ────
+async function handleApi(pathname, method, body, query) {
+ const modules = loadModules();
+ // 每次请求前重置所有 store 缓存,确保读到磁盘最新数据
+ resetAllCaches(modules);
+ const { deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore } = modules;
+
+ // GET /api/status —— 全局概览
+ if (method === "GET" && pathname === "/api/status") {
+ const extMap = deviceStore.getAllExtensions(readStorage);
+ const extArr = Object.values(extMap);
+ const enabled = extArr.filter((e) => e.inspection_enabled).length;
+ const disabled = extArr.length - enabled;
+ const latestAll = inspectionResultStore.getLatestAll(readStorage);
+ const okCnt = latestAll.filter((r) => r?.ok).length;
+ const failCnt = latestAll.filter((r) => r && !r.ok).length;
+ const alerts = alertRecordStore.listAlerts(readStorage, {
+ status: "active",
+ limit: 200,
+ });
+ const critical = alerts.filter((a) => a.level === "critical" || a.level === 3).length;
+ const warning = alerts.filter((a) => a.level === "warning" || a.level === 2).length;
+ const info = alerts.filter((a) => a.level === "info" || a.level === 1).length;
+ return {
+ ok: true,
+ timestamp: new Date().toISOString(),
+ devices: { total: extArr.length, enabled, disabled },
+ latestScan: { total: latestAll.length, ok: okCnt, fail: failCnt },
+ alerts: { total: alerts.length, critical, warning, info },
+ };
+ }
+
+ // GET /api/devices —— 设备列表 + 巡检状态
+ if (method === "GET" && pathname === "/api/devices") {
+ const extMap = deviceStore.getAllExtensions(readStorage);
+ const hostMap = buildHostLabelMap(readStorage, { alertRecordStore, inspectionResultStore });
+ const latestMap = {};
+ const all = inspectionResultStore.getLatestAll(readStorage);
+ for (const r of all) if (r?.device_id) latestMap[r.device_id] = r;
+
+ // catalog_id → 业务类型(switch/server/firewall)
+ function catalogTypeToDeviceType(catalogId) {
+ if (!catalogId) return "server";
+ if (/switch/i.test(catalogId)) return "switch";
+ if (/firewall/i.test(catalogId)) return "firewall";
+ if (/router/i.test(catalogId)) return "router";
+ return "server";
+ }
+
+ const list = Object.values(extMap).map((ext) => {
+ const r = latestMap[ext.host_id];
+ const hi = hostMap[ext.host_id] || {};
+ const label = hi.label || "";
+ const hostname = hi.hostname || ext.hostname || "";
+ const name = label || hostname || ext.host_id;
+ const catalogType = ext.catalog_id || "";
+ const deviceType = catalogTypeToDeviceType(catalogType);
+ return {
+ host_id: ext.host_id,
+ label,
+ hostname,
+ name,
+ catalog_id: catalogType,
+ catalog_type: catalogType, // 原始 catalog id(huawei_h3c_switch/linux 等)
+ distro: catalogType, // 让 classifyHostType 通过 distro 字段能匹配到
+ type: deviceType, // switch/server/firewall/router
+ catalog: catalogType,
+ tier: ext.tier,
+ inspection_enabled: ext.inspection_enabled ?? null,
+ inspection_interval: ext.inspection_interval ?? null,
+ interval_minutes: ext.inspection_interval || 30,
+ last_scan_at: ext.last_scan_at || r?.scanned_at || null,
+ last_ok: r?.ok ?? null,
+ last_error: r?.ok === false ? (r?.error || "") : null,
+ metrics: r?.metrics || {},
+ online: r?.online ?? true,
+ };
+ });
+ return { ok: true, devices: list };
+ }
+
+ // GET /api/alerts —— 活跃告警列表
+ if (method === "GET" && pathname === "/api/alerts") {
+ const alerts = alertRecordStore.listAlerts(readStorage, {
+ status: "active",
+ limit: 200,
+ });
+ return { ok: true, alerts };
+ }
+
+ // GET /api/alert-rules —— 告警规则列表
+ if (method === "GET" && pathname === "/api/alert-rules") {
+ const rules = alertRuleStore.listRules(readStorage);
+ return { ok: true, rules };
+ }
+
+ // 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 —— 最近巡检结果
+ if (method === "GET" && pathname === "/api/inspection/results/recent") {
+ const all = inspectionResultStore.getRecent(readStorage, 50);
+ return { ok: true, results: all };
+ }
+
+ // GET /api/devices/enabled —— 已启用巡检的设备列表
+ if (method === "GET" && pathname === "/api/devices/enabled") {
+ const enabled = deviceStore.getEnabledDevices(readStorage);
+ return { ok: true, devices: enabled };
+ }
+
+ // GET /api/topology-positions —— Electron 拖拽保存的拓扑位置(远程浏览器同步)
+ // 直接读磁盘,绕过 inspectionStorage 内存缓存
+ if (method === "GET" && pathname === "/api/topology-positions") {
+ try {
+ const fs = require("fs");
+ const path = require("path");
+ const app = require("electron").app;
+ const storeFile = path.join(app.getPath("userData"), "inspection-data.json");
+ if (!fs.existsSync(storeFile)) return { ok: true, positions: {} };
+ const all = JSON.parse(fs.readFileSync(storeFile, "utf-8") || "{}");
+ return { ok: true, positions: all._topology_positions || {} };
+ } catch (e) {
+ return { ok: true, positions: {} };
+ }
+ }
+
+ // GET /api/dashboard-title —— 监控大屏标题 + 产品名 + 报告产品名(远程浏览器同步)
+ if (method === "GET" && pathname === "/api/dashboard-title") {
+ try {
+ const { inspectionStorage } = loadModules();
+ return {
+ ok: true,
+ dashboardTitle: inspectionStorage.readStorage("_dashboard_title") || "",
+ productName: inspectionStorage.readStorage("_product_name") || "",
+ reportProductName: inspectionStorage.readStorage("_report_product_name") || "",
+ };
+ } catch {
+ return { ok: true, dashboardTitle: "", productName: "", reportProductName: "" };
+ }
+ }
+
+ // GET /api/topology-links —— 用户手动添加的连线
+ if (method === "GET" && pathname === "/api/topology-links") {
+ try {
+ const fs = require("fs");
+ const path = require("path");
+ const app = require("electron").app;
+ const storeFile = path.join(app.getPath("userData"), "inspection-data.json");
+ if (!fs.existsSync(storeFile)) return { ok: true, links: [] };
+ const all = JSON.parse(fs.readFileSync(storeFile, "utf-8") || "{}");
+ return { ok: true, links: all._topology_links || [] };
+ } catch {
+ return { ok: true, links: [] };
+ }
+ }
+
+ // GET /api/inspection/scheduler-status —— 调度器状态(简化版)
+ if (method === "GET" && pathname === "/api/inspection/scheduler-status") {
+ // 从 inspection-data.json 读调度器状态
+ try {
+ const storeFile = path.join(
+ require("electron").app.getPath("userData"),
+ "inspection-data.json"
+ );
+ if (fs.existsSync(storeFile)) {
+ const all = JSON.parse(fs.readFileSync(storeFile, "utf-8") || "{}");
+ const sched = all["inspection-scheduler"] || {};
+ return {
+ ok: true,
+ running: !!sched.enabled,
+ queue_size: 0,
+ active_scans: 0,
+ concurrency: sched.concurrency || 3,
+ };
+ }
+ } catch {}
+ return { ok: true, running: false, queue_size: 0, active_scans: 0, concurrency: 3 };
+ }
+
+ // GET /api/inspection/catalog —— 设备类型目录
+ if (method === "GET" && pathname === "/api/inspection/catalog") {
+ try {
+ const catalog = deviceStore.getCatalog();
+ return { ok: true, catalog };
+ } catch (e) {
+ console.warn("[RemoteHTTP] catalog error:", e.message);
+ return { ok: true, catalog: [] };
+ }
+ }
+
+ // GET /api/inspection/sys-config —— 系统配置
+ if (method === "GET" && pathname === "/api/inspection/sys-config") {
+ try {
+ const cfg = deviceStore.getSysConfig(readStorage);
+ return { ok: true, data: cfg };
+ } catch {
+ return { ok: true, data: {} };
+ }
+ }
+
+ // GET /api/inspection/labels —— 标签字典
+ if (method === "GET" && pathname === "/api/inspection/labels") {
+ return {
+ ok: true,
+ data: {
+ alert_levels: { critical: "严重", warning: "警告", info: "提示" },
+ device_tiers: { core: "核心", aggregation: "汇聚", access: "接入" },
+ inspection_status: { success: "成功", failed: "失败", timeout: "超时" },
+ alert_record_status: { active: "活跃", acknowledged: "已确认", resolved: "已解决", closed: "已关闭" },
+ operators: { ">": ">", "<": "<", "==": "=", "!=": "!=", ">=": ">=", "<=": "<=" },
+ device_catalog: {},
+ metrics: {},
+ },
+ };
+ }
+
+ // GET /api/alert-stats —— 告警统计
+ if (method === "GET" && pathname === "/api/alert-stats") {
+ const all = alertRecordStore.listAlerts(readStorage, { limit: 500 });
+ const active = all.filter((a) => a.status === "active");
+ const acknowledged = all.filter((a) => a.status === "acknowledged");
+ const resolved = all.filter((a) => a.status === "resolved");
+ const closed = all.filter((a) => a.status === "closed");
+ return {
+ ok: true,
+ data: {
+ total: all.length,
+ active: active.length,
+ active_critical: active.filter((a) => a.level === "critical").length,
+ active_warning: active.filter((a) => a.level === "warning").length,
+ active_info: active.filter((a) => a.level === "info").length,
+ acknowledged: acknowledged.length,
+ resolved: resolved.length,
+ closed: closed.length,
+ },
+ };
+ }
+
+ // GET /api/snapshots —— 巡检快照列表
+ if (method === "GET" && pathname === "/api/snapshots") {
+ try {
+ const storeFile = path.join(
+ require("electron").app.getPath("userData"),
+ "inspection-data.json"
+ );
+ if (!fs.existsSync(storeFile)) return { ok: true, snapshots: [] };
+ const all = JSON.parse(fs.readFileSync(storeFile, "utf-8") || "{}");
+ const snaps = (all.snapshots || []).slice(-30).reverse();
+ return { ok: true, snapshots: snaps };
+ } catch (e) {
+ return { ok: false, error: e.message, snapshots: [] };
+ }
+ }
+
+ // GET /api/report/snapshots —— 巡检报告快照列表(走 reportStore)
+ if (method === "GET" && pathname === "/api/report/snapshots") {
+ try {
+ const { listSnapshots } = require("./bridges/inspection/reportStore.cjs");
+ const list = listSnapshots(readStorage, 50);
+ return { ok: true, snapshots: list || [] };
+ } catch (e) {
+ return { ok: true, snapshots: [] };
+ }
+ }
+
+ // GET /api/report/snapshot-status —— 是否有快照在运行
+ if (method === "GET" && pathname === "/api/report/snapshot-status") {
+ return { ok: true, running: false }; // 远程只读场景不允许执行快照
+ }
+
+ // GET /api/report/schedule —— 报告调度配置
+ if (method === "GET" && pathname === "/api/report/schedule") {
+ try {
+ const { getSchedule } = require("./bridges/inspection/reportScheduleStore.cjs");
+ const cfg = getSchedule(readStorage);
+ return { ok: true, schedule: cfg || null };
+ } catch {
+ return { ok: true, schedule: null };
+ }
+ }
+
+ // GET /api/daily-reports —— 日运行报告列表
+ if (method === "GET" && pathname === "/api/daily-reports") {
+ try {
+ const { listDailyReports } = require("./bridges/inspection/dailyReportStore.cjs");
+ const list = listDailyReports(readStorage, 30);
+ return { ok: true, reports: list || [] };
+ } catch (e) {
+ return { ok: true, reports: [] };
+ }
+ }
+
+ // GET /api/monitoring —— 运行监控聚合数据(renderer fallback 用)
+ if (method === "GET" && pathname === "/api/monitoring") {
+ const extMap = deviceStore.getAllExtensions(readStorage);
+ const hostMap = buildHostLabelMap(readStorage, { alertRecordStore, inspectionResultStore });
+ const extArr = Object.values(extMap);
+ const latestAll = inspectionResultStore.getLatestAll(readStorage);
+ const latestMap = {};
+ for (const r of latestAll) if (r?.device_id) latestMap[r.device_id] = r;
+
+ const alerts = alertRecordStore.listAlerts(readStorage, {
+ status: "active",
+ limit: 200,
+ });
+
+ const rules = alertRuleStore.listRules(readStorage);
+
+ // 每台设备的 metrics(cpu_usage / memory_usage / online)和巡检状态
+ const devices = extArr.map((ext) => {
+ const r = latestMap[ext.host_id];
+ const m = r?.metrics || {};
+ const hi = hostMap[ext.host_id] || {};
+ return {
+ id: ext.host_id,
+ name: hi.label || hi.hostname || ext.hostname || ext.host_id,
+ catalog_id: ext.catalog_id,
+ tier: ext.tier,
+ inspection_enabled: !!ext.inspection_enabled,
+ last_scan_at: ext.last_scan_at || r?.timestamp || null,
+ online: r?.online ?? true,
+ cpu_usage: Number(m.cpu_usage ?? m.cpu_percent ?? m.cpu ?? 0),
+ memory_usage: Number(m.memory_usage ?? m.mem_percent ?? m.memory ?? 0),
+ status: r?.status || null,
+ };
+ });
+
+ return {
+ ok: true,
+ devices,
+ alerts,
+ rules,
+ timestamp: Date.now(),
+ };
+ }
+
+ // POST /api/devices/batch-enable
+ if (method === "POST" && pathname === "/api/devices/batch-enable") {
+ try {
+ const payload = JSON.parse(body || "{}");
+ const enabled = payload.enabled !== false;
+ const all = deviceStore.getAllExtensions(readStorage);
+ let count = 0;
+ for (const ext of Object.values(all)) {
+ deviceStore.upsertExtension(readStorage, (k, v) => _storage?.writeStorage?.(k, v), ext.host_id, {
+ inspection_enabled: enabled,
+ });
+ count++;
+ }
+ return { ok: true, count, enabled };
+ } catch (e) {
+ return { ok: false, error: e.message };
+ }
+ }
+
+ // GET /api/vault/:key —— Vault 子页面通用只读路由
+ // 支持 keys/proxies/port-forwards/snippets/notes/known-hosts/connection-logs/groups/identities
+ // 敏感数据(keys/identities)自动脱敏
+ const _VAULT_ROUTE_MAP = {
+ keys: "_vault_keys",
+ proxies: "_vault_proxies",
+ "port-forwards": "_vault_port_forwards",
+ snippets: "_vault_snippets",
+ notes: "_vault_notes",
+ "known-hosts": "_vault_known_hosts",
+ "connection-logs": "_vault_connection_logs",
+ groups: "_vault_groups",
+ identities: "_vault_identities",
+ };
+ const _SENSITIVE_FIELDS = new Set(["privateKeyContent", "privateKey", "passphrase", "password", "secret", "apiKey", "private_key", "pass_phrase"]);
+ function _stripSensitive(obj) {
+ if (obj === null || obj === undefined) return obj;
+ if (Array.isArray(obj)) return obj.map(_stripSensitive);
+ if (typeof obj === "object") {
+ const out = {};
+ for (const [k, v] of Object.entries(obj)) {
+ if (_SENSITIVE_FIELDS.has(k)) continue;
+ out[k] = _stripSensitive(v);
+ }
+ return out;
+ }
+ return obj;
+ }
+ const vaultMatch = pathname.match(/^\/api\/vault\/([\w-]+)$/);
+ if (method === "GET" && vaultMatch) {
+ const key = vaultMatch[1];
+ const storageKey = _VAULT_ROUTE_MAP[key];
+ if (!storageKey) return { ok: false, error: "unknown vault key" };
+ try {
+ resetAllCaches();
+ const raw = _storage?.readStorage?.(storageKey);
+ let data = raw;
+ if (typeof raw === "string") {
+ try { data = JSON.parse(raw); } catch { data = raw; }
+ }
+ // keys 和 identities 需要脱敏
+ if (key === "keys" || key === "identities") {
+ data = _stripSensitive(data);
+ }
+ return { ok: true, data: data ?? (Array.isArray(data) ? [] : {}) };
+ } catch (e) {
+ return { ok: false, error: e?.message };
+ }
+ }
+
+ // GET /api/report-preview —— 远程浏览器动态生成 PDF 并返回流
+ // query: mode=snapshot|daily&id=
+ if (method === "GET" && pathname === "/api/report-preview") {
+ const mode = query.mode;
+ const id = query.id;
+ if (!mode || !id) return { ok: false, error: "mode and id required" };
+
+ try {
+ const { app } = require("electron");
+ const reportStore = require("./bridges/inspection/reportStore.cjs");
+ const dailyReportStore = require("./bridges/inspection/dailyReportStore.cjs");
+ const reportExport = require("./bridges/inspection/reportExport.cjs");
+ const fs = require("fs");
+ const path = require("path");
+ const inspectionStorage = require("./bridges/inspection/inspectionStorage.cjs");
+
+ // 读产品名
+ const storageFile = path.join(app.getPath("userData"), "inspection-data.json");
+ let systemName = "NetMesh 巡检报告";
+ try {
+ const all = JSON.parse(fs.readFileSync(storageFile, "utf-8") || "{}");
+ systemName = all._product_name || all._dashboard_title || systemName;
+ } catch {}
+
+ let filePath;
+ if (mode === "snapshot") {
+ reportStore._resetForTest?.();
+ const snap = reportStore.getSnapshot(readStorage, id);
+ if (!snap) return { ok: false, error: "快照不存在" };
+ filePath = await reportExport.exportSnapshotPdf(app, snap, snap.ai_summary || undefined, null, systemName);
+ } else if (mode === "daily") {
+ dailyReportStore._resetForTest?.();
+ const report = dailyReportStore.getReport(readStorage, id);
+ if (!report) return { ok: false, error: "日报告不存在" };
+ reportStore._resetForTest?.();
+ const allSnapshots = reportStore.listSnapshots(readStorage);
+ filePath = await reportExport.exportDailyReportPdf(app, report, null, allSnapshots, systemName);
+ } else {
+ return { ok: false, error: "未知 mode" };
+ }
+
+ if (!fs.existsSync(filePath)) return { ok: false, error: "PDF 生成失败" };
+ const stat = fs.statSync(filePath);
+ const content = fs.readFileSync(filePath);
+ // 异步清理临时 PDF(30 秒后)
+ setTimeout(() => { try { fs.unlinkSync(filePath); } catch {} }, 30000);
+
+ return {
+ __raw: true,
+ writeHead: (res) => {
+ res.writeHead(200, {
+ "Content-Type": "application/pdf",
+ "Content-Length": stat.size,
+ "Cache-Control": "no-cache",
+ "Content-Disposition": `inline; filename="${path.basename(filePath)}"`,
+ });
+ },
+ end: (res) => res.end(content),
+ };
+ } catch (e) {
+ console.error("[report-preview] 失败:", e.message);
+ return { ok: false, error: e.message };
+ }
+ }
+
+ // POST /api/run-report-ai-summary —— 巡检报告 AI 汇总(异步 setImmediate 执行)
+ if (method === "POST" && pathname === "/api/run-report-ai-summary") {
+ let snapshotId, providerId;
+ try {
+ const bodyObj = typeof body === "string" ? JSON.parse(body || "{}") : (body || {});
+ snapshotId = bodyObj.snapshotId;
+ providerId = bodyObj.providerId;
+ } catch {}
+ if (!snapshotId) return { ok: false, error: "snapshotId required" };
+ try {
+ // 触发方式:让已注册的 IPC handler 自己被调用
+ // index.cjs 里 registerInspectionHandlers 注册了 "inspection:runReportAiSummary"
+ // 我们无法直接 require 到内部函数,但可以用 event sender = null 来触发 handler
+ setImmediate(async () => {
+ try {
+ const { ipcMain } = require("electron");
+ // ipcMain handlers 不支持 emit 触发,改用 direct call
+ // 直接 require 并调用 reportStore + aiBridge 自行实现
+ const reportStore = require("./bridges/inspection/reportStore.cjs");
+ const bridge = require("./bridges/aiBridge.cjs");
+ if (!bridge.providerConfigs?.length) {
+ console.warn("[RemoteHTTP] run-report-ai-summary: 无 AI provider 配置");
+ return;
+ }
+ const readStorageFn = (k) => { try { return _storage?.readStorage?.(k); } catch { return null; } };
+ const writeStorageFn = (k, v) => { try { _storage?.writeStorage?.(k, v); } catch {} };
+
+ const snap = reportStore.getSnapshot(readStorageFn, snapshotId);
+ if (!snap) { console.warn("[RemoteHTTP] snapshot 不存在"); return; }
+
+ reportStore.setSnapshotAiStatus(readStorageFn, writeStorageFn, snapshotId, "running");
+
+ const activeProviders = bridge.providerConfigs;
+ const provider = activeProviders.find(p => p.id === providerId)
+ || activeProviders.find(p => p.enabled !== false);
+ if (!provider) throw new Error("AI provider 未配置");
+ const resolved = bridge.resolveProviderApiKey(provider.id);
+ if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
+
+ // 压缩构建 prompt —— 复用 index.cjs 里的 buildSnapshotPrompt
+ // 但它在内部没导出,简单手动构建:
+ const compactDevices = (snap.devices || []).map(d => {
+ const cmds = (d.commands || [])
+ .filter(c => !c.ok) // 只留失败命令
+ .concat((d.commands || []).filter(c => c.ok && /cpu|mem|disk|uptime/i.test(c.command)).slice(0, 5))
+ .map(c => ({ cmd: c.command?.slice(0, 40) || "", ok: c.ok, out: (c.stdout || c.stderr || "").slice(0, 200) }));
+ return { name: d.label || d.hostname || d.host_id, type: d.device_type || d.catalog_id, ip: d.hostname, cmds };
+ });
+ const prompt = JSON.stringify({
+ system: "你是网络运维专家。分析以下设备巡检数据,输出 JSON:{health_summary, offline_devices[], hidden_risks[], recommendations[]}。禁止输出外网URL、厂商文档链接、外部工具推荐。",
+ data: { devices: compactDevices, time: new Date(snap.created_at).toISOString() }
+ });
+
+ const url = provider.endpoint || "https://api.minimaxi.com/v1/chat/completions";
+ const model = provider.model || "MiniMax-M1";
+ const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${resolved.apiKey}` };
+ const bodyStr = JSON.stringify({ model, messages: [{ role: "user", content: prompt }], temperature: 0.3, max_tokens: 1500 });
+
+ // HTTP fetch
+ const http = require("http"), https = require("https");
+ const lib = url.startsWith("https") ? https : http;
+ const u = new URL(url);
+ const hreq = lib.request({ hostname: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80), path: u.pathname + u.search, method: "POST", headers }, hres => {
+ let chunks = []; hres.on("data", c => chunks.push(c)); hres.on("end", () => {
+ try {
+ const resp = JSON.parse(Buffer.concat(chunks).toString());
+ const text = resp.choices?.[0]?.message?.content || "";
+ const js = JSON.parse(text.replace(/```json|```/g, "").trim());
+ reportStore.updateSnapshotAiSummary(readStorageFn, writeStorageFn, snapshotId, js);
+ reportStore.setSnapshotAiStatus(readStorageFn, writeStorageFn, snapshotId, "done");
+ console.log("[RemoteHTTP] AI 汇总完成");
+ } catch (e) {
+ reportStore.updateSnapshotAiSummary(readStorageFn, writeStorageFn, snapshotId, null, e.message);
+ reportStore.setSnapshotAiStatus(readStorageFn, writeStorageFn, snapshotId, "failed", e.message);
+ console.error("[RemoteHTTP] AI 解析失败:", e.message);
+ }
+ });
+ });
+ hreq.on("error", e => {
+ reportStore.setSnapshotAiStatus(readStorageFn, writeStorageFn, snapshotId, "failed", e.message);
+ console.error("[RemoteHTTP] AI HTTP 错误:", e.message);
+ });
+ hreq.write(bodyStr);
+ hreq.end();
+ } catch (e) {
+ console.error("[RemoteHTTP] run-report-ai-summary:", e?.message || String(e));
+ }
+ });
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: e?.message || String(e) };
+ }
+ }
+
+ // POST /api/generate-daily-report —— 日运行报告生成(同步)
+ if (method === "POST" && pathname === "/api/generate-daily-report") {
+ let mode = "provisional", trigger = "manual";
+ try {
+ const bodyObj = typeof body === "string" ? JSON.parse(body || "{}") : (body || {});
+ if (bodyObj.mode) mode = bodyObj.mode;
+ if (bodyObj.trigger) trigger = bodyObj.trigger;
+ } catch {}
+ try {
+ const reportScheduler = require("./bridges/inspection/reportScheduler.cjs");
+ const writeStorage = (k, v) => {
+ try { require("./bridges/inspection/inspectionStorage.cjs").setItem?.(k, v); } catch {}
+ };
+ const report = await reportScheduler.triggerDailyReport(readStorage, writeStorage, { mode, trigger });
+ return { ok: true, report };
+ } catch (e) {
+ return { ok: false, error: e?.message || String(e) };
+ }
+ }
+
+ // POST /api/run-alert-rca —— 远程告警中心 AI 根因分析
+ // 委托给主进程完整 RCA:Phase 1 AI 规划 + SSH 诊断 + Phase 2 AI 综合
+ // 用主进程内部的 _hostConfigs 直接拿 SSH 凭据,不需要 renderer 解密
+ if (method === "POST" && pathname === "/api/run-alert-rca") {
+ let alertId;
+ try {
+ const bodyObj = typeof body === "string" ? JSON.parse(body || "{}") : (body || {});
+ alertId = bodyObj.alertId;
+ } catch {}
+ if (!alertId) return { ok: false, error: "alertId required" };
+ try {
+ resetAllCaches();
+ const inspectionIndex = require("./bridges/inspection/index.cjs");
+ if (!inspectionIndex.runAlertRCA) {
+ return { ok: false, error: "RCA handler 未初始化(需重启 Electron 主进程)" };
+ }
+ console.log("[RemoteHTTP] 委托主进程执行完整 RCA, alertId:", alertId);
+ const result = await inspectionIndex.runAlertRCA(alertId);
+ console.log("[RemoteHTTP] RCA 完成, success:", result?.success);
+ if (result?.success) {
+ const r = result.rca || {};
+ return {
+ ok: true,
+ rca: {
+ summary: r.summary,
+ confidence: r.confidence,
+ rootCauses: r.rootCauses,
+ evidence: r.evidence,
+ recommendations: r.recommendations,
+ },
+ durationMs: result.durationMs,
+ };
+ }
+ return { ok: false, error: result?.error || "RCA 执行失败" };
+ } catch (e) {
+ console.error("[RemoteHTTP] RCA 异常:", e.message);
+ return { ok: false, error: e?.message || String(e) };
+ }
+ }
+
+ return { ok: false, error: "Not found" };
+}
+
+// ─── 主入口 ────
+async function start(options) {
+ _storage = options?.storage;
+
+ // 读配置
+ let enabled = false;
+ let port = 8848;
+ let savedToken = null;
+ try {
+ const raw = _storage?.readStorage?.(STORAGE_KEY);
+ if (raw) {
+ const cfg = JSON.parse(raw);
+ enabled = !!cfg.enabled;
+ port = Number(cfg.port) || 8848;
+ savedToken = cfg.token || null;
+ }
+ } catch {}
+
+ if (!enabled) {
+ console.log("[RemoteHTTP] 远程访问未启用,跳过启动");
+ return { started: false };
+ }
+
+ if (_server) {
+ console.log("[RemoteHTTP] 已在运行,端口", _port);
+ return { started: true, port: _port, token: _token };
+ }
+
+ _token = savedToken || crypto.randomBytes(12).toString("hex");
+ _port = port;
+
+ // 启动前持久化 token(如果是新生成的)
+ if (!savedToken) {
+ try {
+ _storage?.writeStorage?.(STORAGE_KEY, JSON.stringify({ enabled: true, port: _port, token: _token }));
+ } catch {}
+ }
+
+ // 校验 dist 是否存在
+ const distDir = resolveDistDir();
+ const indexHtml = path.join(distDir, "index.html");
+ if (!fs.existsSync(indexHtml)) {
+ console.warn("[RemoteHTTP] dist/index.html 不存在,远程访问页面将不可用。尝试路径:", distDir);
+ }
+
+ _server = http.createServer(async (req, res) => {
+ const { pathname, query } = parseQuery(req.url);
+
+ // favicon.ico — 浏览器自动请求,返回 204 空响应避免 404
+ if (pathname === "/favicon.ico") {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+
+ // /api/* —— 要 token
+ if (pathname.startsWith("/api/")) {
+ if (query.token !== _token) {
+ sendJson(res, 401, { ok: false, error: "Unauthorized" });
+ return;
+ }
+ try {
+ const body = await readBody(req);
+ const result = await handleApi(pathname, req.method || "GET", body, query);
+ if (result?.__raw) {
+ result.writeHead(res);
+ result.end(res);
+ } else {
+ sendJson(res, result.ok ? 200 : (result.error === "Not found" ? 404 : 400), result);
+ }
+ } catch (e) {
+ console.error("[RemoteHTTP] API error:", e);
+ sendJson(res, 500, { ok: false, error: e.message });
+ }
+ return;
+ }
+
+ // 静态入口(/、/index.html、任意 SPA fallback)—— 要 token,且注入 window.__REMOTE_TOKEN__
+ // 静态资源 /assets/* /monaco/* 不强制 token(已经在 index.html 里,token 校验在入口做)
+ const isAssetPath =
+ pathname.startsWith("/assets/") ||
+ pathname.startsWith("/monaco/") ||
+ pathname.startsWith("/fonts/") ||
+ pathname.startsWith("/images/") ||
+ pathname.startsWith("/public/") ||
+ pathname.endsWith(".png") ||
+ pathname.endsWith(".jpg") ||
+ pathname.endsWith(".jpeg") ||
+ pathname.endsWith(".svg") ||
+ pathname.endsWith(".ico") ||
+ pathname.endsWith(".woff") ||
+ pathname.endsWith(".woff2") ||
+ pathname.endsWith(".ttf") ||
+ pathname.endsWith(".eot") ||
+ pathname.endsWith(".mp3") ||
+ pathname.endsWith(".wasm") ||
+ pathname.endsWith(".map");
+
+ if (isAssetPath) {
+ serveStatic(res, pathname);
+ return;
+ }
+
+ // SPA 入口 —— 所有非API、非资源的路径都 serve index.html + token 注入
+ if (query.token !== _token) {
+ sendHtml(res, 200,
+ 'NetMesh' +
+ '' +
+ 'NetMesh 远程访问
' +
+ '缺少或错误的 token 参数
' +
+ '请从 NetMesh 设置页获取完整 URL
' +
+ ''
+ );
+ return;
+ }
+
+ // 读 dist/index.html 并在 前注入 token(唯一一处,避免破坏其他标签)
+ try {
+ const htmlPath = path.join(resolveDistDir(), "index.html");
+ let html = fs.readFileSync(htmlPath, "utf-8");
+ const inject = ``;
+ // 只注入一处: 前最稳妥(Vite 模板里 只有一处)
+ html = html.replace("", inject + "");
+ sendHtml(res, 200, html);
+ } catch (e) {
+ sendHtml(res, 500,
+ 'NetMesh 远程访问
dist/index.html 加载失败: ' + e.message + '
'
+ );
+ }
+ });
+
+ return new Promise((resolve) => {
+ _server.listen(_port, "0.0.0.0", () => {
+ const url = `http://0.0.0.0:${_port}/?token=${_token}`;
+ console.log("[RemoteHTTP] ✅ 远程 HTTP 服务已启动");
+ console.log("[RemoteHTTP] 监听端口:", _port);
+ console.log("[RemoteHTTP] 访问 URL:", url);
+ console.log("[RemoteHTTP] dist 目录:", resolveDistDir());
+ resolve({ started: true, port: _port, token: _token, url });
+ });
+ _server.on("error", (err) => {
+ console.error("[RemoteHTTP] 启动失败:", err.message);
+ _server = null;
+ resolve({ started: false, error: err.message });
+ });
+ });
+}
+
+function stop() {
+ return new Promise((resolve) => {
+ if (!_server) return resolve({ stopped: false });
+ _server.close(() => {
+ console.log("[RemoteHTTP] 已停止");
+ _server = null;
+ resolve({ stopped: true });
+ });
+ });
+}
+
+function getStatus() {
+ return {
+ running: !!_server,
+ port: _port,
+ token: _token,
+ };
+}
+
+// IPC handler 设置:enable/disable + restart
+async function configure(enabled, newPort) {
+ try {
+ // 读现有配置
+ let cfg = { enabled, port: newPort || 8848, token: null };
+ const raw = _storage?.readStorage?.(STORAGE_KEY);
+ if (raw) {
+ const old = JSON.parse(raw);
+ cfg.token = old.token || cfg.token;
+ cfg.port = newPort || old.port || 8848;
+ }
+ // 启用但 token 没生成 → 先生成 token 再启动
+ if (enabled && !cfg.token) cfg.token = crypto.randomBytes(12).toString("hex");
+ _storage?.writeStorage?.(STORAGE_KEY, JSON.stringify(cfg));
+
+ if (enabled) {
+ await stop();
+ const info = await start({ storage: _storage });
+ return { ok: true, ...info };
+ } else {
+ await stop();
+ return { ok: true, stopped: true };
+ }
+ } catch (e) {
+ return { ok: false, error: e.message };
+ }
+}
+
+module.exports = { start, stop, getStatus, configure };
diff --git a/infrastructure/inspection/inspectionApi.ts b/infrastructure/inspection/inspectionApi.ts
index a245021..bd9509d 100644
--- a/infrastructure/inspection/inspectionApi.ts
+++ b/infrastructure/inspection/inspectionApi.ts
@@ -130,16 +130,137 @@ export interface SchedulerStatus {
const api = (window as any).netcatty?.inspection;
+// 浏览器远程访问环境:通过 HTTP API fallback
+const IS_REMOTE_BROWSER = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
+const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || "";
+
+const REMOTE_API_MAP: Record = {
+ getLatestAll: { method: "GET", path: "/api/inspection/results", arrayField: "results" },
+ getLatestByDevice: { method: "GET", path: "/api/inspection/results", arrayField: "results" },
+ getRecentResults: { method: "GET", path: "/api/inspection/results/recent", arrayField: "results" },
+ getActiveAlerts: { method: "GET", path: "/api/alerts", arrayField: "alerts" },
+ listAlerts: { method: "GET", path: "/api/alerts", arrayField: "alerts" },
+ getAlertStats: { method: "GET", path: "/api/alert-stats" },
+ listRules: { method: "GET", path: "/api/alert-rules", arrayField: "rules" },
+ getRule: { method: "GET", path: "/api/alert-rules", arrayField: "rules" },
+ getAllExtensions: { method: "GET", path: "/api/devices", arrayField: "devices" },
+ getEnabledDevices: { method: "GET", path: "/api/devices/enabled", arrayField: "devices" },
+ schedulerStatus: { method: "GET", path: "/api/inspection/scheduler-status" },
+ getCatalog: { method: "GET", path: "/api/inspection/catalog", arrayField: "catalog" },
+ getSysConfig: { method: "GET", path: "/api/inspection/sys-config" },
+ getLabels: { method: "GET", path: "/api/inspection/labels" },
+ listReportSnapshots: { method: "GET", path: "/api/report/snapshots", arrayField: "snapshots" },
+ isReportSnapshotRunning: { method: "GET", path: "/api/report/snapshot-status" },
+ getReportSchedule: { method: "GET", path: "/api/report/schedule" },
+ listDailyReports: { method: "GET", path: "/api/daily-reports", arrayField: "reports" },
+ batchSetInspectionEnabled: { method: "POST", path: "/api/devices/batch-enable" },
+ runReportAiSummary: { method: "POST", path: "/api/run-report-ai-summary" },
+ generateDailyReport: { method: "POST", path: "/api/generate-daily-report" },
+};
+
+// 某些 API 返回数组 —— 在 REMOTE_API_MAP 里用 arrayField 标记
+// 找不到映射的 API 默认返回 null,让调用方自己兜底
+const ARRAY_METHODS = new Set([
+ "listReportSnapshots", "listDailyReports", "listRules", "getLatestAll",
+ "getLatestByDevice", "getRecentResults", "getActiveAlerts", "listAlerts",
+ "getAllExtensions", "getEnabledDevices", "getCatalog",
+]);
+
+async function remoteFetch(name: string, ...args: any[]): Promise {
+ const mapping = REMOTE_API_MAP[name];
+ if (!mapping) {
+ // 没有映射的方法:读场景返回空数组/空对象兜底,避免 .find 崩溃
+ console.warn(`[Inspection] remote fallback: no mapping for ${name}, returning safe default`);
+ if (ARRAY_METHODS.has(name)) return [];
+ if (name === "getSchedule" || name === "getReportSchedule") return {};
+ if (name === "getSysConfig") return {};
+ if (name === "getLabels") return { alert_levels: {}, device_tiers: {}, inspection_status: {}, alert_record_status: {}, operators: {}, device_catalog: {}, metrics: {} };
+ if (name === "schedulerStatus" || name === "isReportSnapshotRunning") return { running: false };
+ return null;
+ }
+ let url = `${mapping.path}?token=${encodeURIComponent(REMOTE_TOKEN)}`;
+ if (name === "getLatestByDevice" && args[0]) {
+ url += `&device_id=${encodeURIComponent(args[0])}`;
+ }
+ if ((name === "listAlerts" || name === "listReportSnapshots") && args[0] !== undefined) {
+ const opts = typeof args[0] === "object" ? args[0] : { limit: args[0] };
+ if (opts.status) url += `&status=${opts.status}`;
+ if (opts.level) url += `&level=${opts.level}`;
+ if (opts.device_id) url += `&device_id=${opts.device_id}`;
+ if (opts.limit) url += `&limit=${opts.limit}`;
+ } else if (args[0] !== undefined && name !== "getRule") {
+ url += `&limit=${encodeURIComponent(args[0])}`;
+ }
+ try {
+ const fetchOpts: RequestInit = {};
+ if (mapping.method === "POST") {
+ fetchOpts.method = "POST";
+ fetchOpts.headers = { "Content-Type": "application/json" };
+ if (name === "batchSetInspectionEnabled") {
+ fetchOpts.body = JSON.stringify({ enabled: !!args[0] });
+ } else if (name === "runReportAiSummary") {
+ // invoke("runReportAiSummary", snapshotId, providerId)
+ fetchOpts.body = JSON.stringify({ snapshotId: args[0], providerId: args[1] });
+ } else if (name === "generateDailyReport") {
+ // invoke("generateDailyReport", options)
+ fetchOpts.body = JSON.stringify(args[0] || {});
+ } else {
+ fetchOpts.body = JSON.stringify(args[0] !== undefined ? args[0] : {});
+ }
+ }
+ const res = await fetch(url, fetchOpts);
+ const data = await res.json();
+ // 适配不同返回结构
+ if (data && typeof data === "object") {
+ if (Array.isArray(data)) return data;
+ // 优先用 arrayField 约定的字段
+ if (mapping.arrayField && Array.isArray(data[mapping.arrayField])) {
+ return data[mapping.arrayField];
+ }
+ if (data.alerts !== undefined) return data.alerts;
+ if (data.devices !== undefined) {
+ if (name === "getAllExtensions") {
+ // getAllExtensions 返回 Record
+ const map: Record = {};
+ for (const d of data.devices) {
+ map[d.host_id] = d;
+ }
+ return map;
+ }
+ return data.devices;
+ }
+ if (data.rules !== undefined) return data.rules;
+ if (data.results !== undefined) return data.results;
+ if (data.snapshots !== undefined) return data.snapshots;
+ if (data.reports !== undefined) return data.reports;
+ if (data.schedule !== undefined) return data.schedule;
+ if (data.ok === true && data.data !== undefined) return data.data;
+ if ("running" in data) return data; // schedulerStatus / snapshot-status
+ return data;
+ }
+ return null;
+ } catch (e) {
+ console.warn(`[Inspection] remote fetch ${name} failed:`, e);
+ // fetch 失败也返回安全默认值,不要让 .find 崩
+ if (ARRAY_METHODS.has(name)) return [];
+ return null;
+ }
+}
+
function isAvailable(): boolean {
- return !!api;
+ return !!api || IS_REMOTE_BROWSER;
}
function invoke(name: string, ...args: any[]): Promise {
- if (!api || typeof api[name] !== "function") {
- console.warn(`[Inspection] api.${name} 不可用`);
- return Promise.resolve(null);
+ if (api && typeof api[name] === "function") {
+ return api[name](...args);
}
- return api[name](...args);
+ // Electron bridge 不可用 → 浏览器远程访问环境走 HTTP fallback
+ if (IS_REMOTE_BROWSER) {
+ return remoteFetch(name, ...args);
+ }
+ console.warn(`[Inspection] api.${name} 不可用`);
+ return Promise.resolve(null);
}
// ---------- 标签 ----------
@@ -167,6 +288,8 @@ export const inspectionDeviceApi = {
get: (hostId: string): Promise => invoke("getExtension", hostId),
upsert: (hostId: string, data: Partial): Promise =>
invoke("upsertExtension", hostId, data),
+ batchSetEnabled: (enabled: boolean): Promise<{ ok: boolean; count: number; enabled: boolean; error?: string }> =>
+ invoke("batchSetInspectionEnabled", enabled),
remove: (hostId: string): Promise => invoke("deleteExtension", hostId),
getEffectiveInterval: (hostId: string): Promise =>
invoke("getEffectiveInterval", hostId),
@@ -229,6 +352,8 @@ export const inspectionSchedulerApi = {
status: (): Promise => invoke("schedulerStatus"),
scanNow: (hostId: string): Promise => invoke("scanNow", hostId),
scanAllNow: (): Promise => invoke("scanAllNow"),
+ getAutoEnabled: (): Promise => invoke("getAutoEnabled"),
+ setAutoEnabled: (enabled: boolean): Promise<{ enabled: boolean }> => invoke("setAutoEnabled", enabled),
};
// ---------- 单次巡检执行 ----------
diff --git a/scripts/migratePortableLocalStorage.cjs b/scripts/migratePortableLocalStorage.cjs
new file mode 100644
index 0000000..11de2b2
--- /dev/null
+++ b/scripts/migratePortableLocalStorage.cjs
@@ -0,0 +1,99 @@
+/**
+ * 离线迁移脚本:把 dev Local Storage leveldb 里所有
+ * http://localhost:5173\x00\x01 前缀的 key
+ * 替换成
+ * app://netcatty\x00\x01
+ *
+ * 运行时机:绿色便携版打包完、给用户之前执行一次。
+ * 之后 Electron 启动时直接就能读正确 origin 的数据。
+ *
+ * 用法:node scripts/migratePortableLocalStorage.cjs
+ * 例:node scripts/migratePortableLocalStorage.cjs release-green\win-unpacked\data
+ *
+ * 需要:level 包(npm i --no-save level)
+ */
+
+const fs = require("node:fs");
+const path = require("node:path");
+const { Level } = require("level");
+
+const DEV_ORIGIN = "http://localhost:5173";
+const PKG_ORIGIN = "app://netcatty";
+const KEY_SEP = Buffer.from([0x00, 0x01]);
+// Chromium Local Storage leveldb key 格式:
+// _\x00\x01
+// 注意:origin 前有个下划线前缀!
+const DEV_ORIGIN_FULL = "_" + DEV_ORIGIN;
+const PKG_ORIGIN_FULL = "_" + PKG_ORIGIN;
+
+function findSep(keyBuf, from) {
+ for (let i = from; i < keyBuf.length - 1; i++) {
+ if (keyBuf[i] === 0x00 && keyBuf[i + 1] === 0x01) return i;
+ }
+ return -1;
+}
+
+async function main() {
+ const dataDir = process.argv[2];
+ if (!dataDir) { console.error("usage: node migratePortableLocalStorage.cjs "); process.exit(2); }
+ const lsDir = path.join(path.resolve(dataDir), "Local Storage", "leveldb");
+ if (!fs.existsSync(lsDir)) { console.error("leveldb dir not found:", lsDir); process.exit(2); }
+
+ // 先看看 origin 统计
+ const db = new Level(lsDir, { keyEncoding: "buffer", valueEncoding: "buffer", createIfMissing: false });
+ const counts = new Map(); // origin -> count
+ const ops = [];
+ let scanned = 0, migrated = 0;
+
+ for await (const [keyBuf, valBuf] of db.iterator()) {
+ scanned++;
+ const sepIdx = findSep(keyBuf, 0);
+ if (sepIdx < 0) continue;
+ const origin = keyBuf.subarray(0, sepIdx).toString("utf-8");
+ counts.set(origin, (counts.get(origin) || 0) + 1);
+
+ if (origin === DEV_ORIGIN_FULL) {
+ // 新 key:_app://netcatty + \x00\x01 + actualKey
+ const newKey = Buffer.concat([Buffer.from(PKG_ORIGIN_FULL, "utf-8"), KEY_SEP, keyBuf.subarray(sepIdx + 2)]);
+ ops.push({ type: "put", key: newKey, value: valBuf });
+ ops.push({ type: "del", key: keyBuf });
+ migrated++;
+ }
+ }
+ await db.close();
+
+ console.log(`扫描 ${scanned} 个 key,origin 分布:`);
+ for (const [o, c] of counts) console.log(` ${o || "(无 origin)"} → ${c}`);
+
+ if (migrated === 0) {
+ console.log("\n没有 http://localhost:5173 前缀的 key 需要迁移。跳过。");
+ return;
+ }
+
+ // 备份当前 leveldb
+ const bak = lsDir + ".bak_" + new Date().toISOString().replace(/[:.]/g, "-");
+ fs.cpSync(lsDir, bak, { recursive: true });
+ console.log(`\n已备份 leveldb → ${bak}`);
+
+ // 执行迁移
+ const db2 = new Level(lsDir, { keyEncoding: "buffer", valueEncoding: "buffer", createIfMissing: false });
+ await db2.batch(ops);
+ await db2.close();
+ console.log(`迁移完成:${migrated} 个 key 从 ${DEV_ORIGIN} → ${PKG_ORIGIN}`);
+
+ // 验证
+ const db3 = new Level(lsDir, { keyEncoding: "buffer", valueEncoding: "buffer", createIfMissing: false });
+ const after = new Map();
+ for await (const [k] of db3.iterator()) {
+ const sep = findSep(k, 0);
+ if (sep >= 0) {
+ const o = k.subarray(0, sep).toString("utf-8");
+ after.set(o, (after.get(o) || 0) + 1);
+ }
+ }
+ await db3.close();
+ console.log("\n迁移后 origin 分布:");
+ for (const [o, c] of after) console.log(` ${o || "(无)"} → ${c}`);
+}
+
+main().catch(e => { console.error("FATAL:", e.message); process.exit(1); });
diff --git a/scripts/scanWorkspaceDeps.cjs b/scripts/scanWorkspaceDeps.cjs
new file mode 100644
index 0000000..d7bed66
--- /dev/null
+++ b/scripts/scanWorkspaceDeps.cjs
@@ -0,0 +1,48 @@
+/**
+ * 扫描根 package.json + 所有 workspace 子包的 dependencies/optionalDependencies,
+ * 找出 workspace 子包引用但根 package.json 没声明的运行时依赖(hoist 到根 node_modules 了)。
+ *
+ * 这些包 electron-builder 收集器看不到(它只读根 package.json),
+ * 会导致打包后 MODULE_NOT_FOUND。需要加到根 package.json 里。
+ */
+const fs = require("node:fs");
+const path = require("node:path");
+
+const rootDir = path.resolve(__dirname, "..");
+const rootPkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf-8"));
+
+// workspace 配置
+let globs = rootPkg.workspace || rootPkg.pnpm?.packages || ["packages/*"];
+if (!Array.isArray(globs)) globs = ["packages/*"];
+
+const rootRuntime = { ...rootPkg.dependencies, ...rootPkg.optionalDependencies };
+
+const subRuntime = {}; // 子包独有的运行时依赖 → 版本
+
+for (const g of globs) {
+ const dir = path.join(rootDir, g.replace("/*", ""));
+ if (!fs.existsSync(dir)) continue;
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const e of entries) {
+ if (!e.isDirectory()) continue;
+ const pkgPath = path.join(dir, e.name, "package.json");
+ if (!fs.existsSync(pkgPath)) continue;
+ const pkgJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
+ const deps = { ...pkgJson.dependencies, ...pkgJson.optionalDependencies };
+ for (const [name, version] of Object.entries(deps)) {
+ if (!rootRuntime[name] && !subRuntime[name]) {
+ subRuntime[name] = version;
+ }
+ }
+ }
+}
+
+const missingRootDeps = Object.keys(subRuntime);
+console.log("子包独有的运行时依赖(根 package.json 没声明):");
+console.log(" 数量:", missingRootDeps.length);
+console.log(" 列表:", missingRootDeps.join(", "));
+console.log("");
+console.log("根 package.json 已有运行时依赖:", Object.keys(rootRuntime).length);
+console.log("");
+console.log("建议加到根 package.json.dependencies 的条目:");
+console.log(JSON.stringify(subRuntime, null, 2));