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));