diff --git a/application/app/AppSideEffects.tsx b/application/app/AppSideEffects.tsx index 2e035d0..cfd2a58 100644 --- a/application/app/AppSideEffects.tsx +++ b/application/app/AppSideEffects.tsx @@ -126,6 +126,59 @@ export function AppSideEffects() { } catch {} }, []); + // 设置权威源同步(主窗口 Electron ↔ 远程浏览器 共享权威源) + // 1. 启动时:先从权威源拉取填 localStorage(远程先改了数据不丢失), + // 再把 localStorage 里可能的新增 merge 回权威源(单条 syncBatch) + // 2. 运行时:每次 localStorageAdapter 写调 IPC → 权威源 + // 3. 主进程广播:远程浏览器改了设置 → 主进程发 IPC → 主窗口静默刷新 localStorage + useEffect(() => { + const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__; + if (isRemote) return; // 远程浏览器在 VaultViewLayout 里走 fetch 路径 + const nc = (window as any).netcatty; + if (!nc?.settingsSync) return; + + (globalThis as any).__NETCATTY_SETTINGS_SYNC__ = (key: string, value: string | null) => { + nc.settingsSync(key, value).catch(() => {}); + }; + + // 实时更新订阅:远程浏览器改了设置 → 主进程广播 → 静默刷新 localStorage + let unsub: null | (() => void) = null; + if (nc.onSettingsChanged) { + unsub = nc.onSettingsChanged(() => { /* 静默刷新,localStorage 已被 preload 填充 */ }); + } + + // 启动时:先拉后推 + (async () => { + try { + // ① 先从权威源拉取(远程可能先改了数据) + const remoteRes = nc.settingsGetAll ? await nc.settingsGetAll() : null; + if (remoteRes?.ok && remoteRes.settings && typeof remoteRes.settings === "object") { + // 权威源值覆盖 localStorage(远程改的优先) + for (const [k, v] of Object.entries(remoteRes.settings)) { + if (v === null || v === undefined) localStorage.removeItem(k); + else localStorage.setItem(k, String(v)); + } + } + // ② 再把 localStorage 里所有键 merge 回权威源(本地可能有新增) + const all = {} as Record; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k) all[k] = localStorage.getItem(k) ?? ""; + } + if (Object.keys(all).length > 0 && nc.settingsSyncBatch) { + await nc.settingsSyncBatch(all); + } + } catch {} + })(); + + return () => { + if ((globalThis as any).__NETCATTY_SETTINGS_SYNC__) { + (globalThis as any).__NETCATTY_SETTINGS_SYNC__ = null; + } + unsub?.(); + }; + }, []); + const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false); const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false); // Combined state for the AddToWorkspaceDialog. null = closed; mode diff --git a/application/state/settingsIpcSync.ts b/application/state/settingsIpcSync.ts index d402cb6..069ff5e 100644 --- a/application/state/settingsIpcSync.ts +++ b/application/state/settingsIpcSync.ts @@ -159,13 +159,21 @@ export function useSettingsIpcSync({ setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState, }: UseSettingsIpcSyncParams) { - // Listen for settings changes from other windows via IPC + // Listen for settings changes from other windows via IPC. + // Preload (preload.cjs) owns the single upstream IPC listener and dispatches + // a `netcatty-settings-batch` CustomEvent after bulk-updating localStorage. + // We listen to that CustomEvent here instead of registering a second IPC + // listener (which would double-fire settingsGetAll + localStorage writes). useEffect(() => { if (!enabled) return; - const bridge = netcattyBridge.get(); - if (!bridge?.onSettingsChanged) return; - const unsubscribe = bridge.onSettingsChanged((payload) => { - const { key, value } = payload; + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail || typeof detail !== 'object') return; + // 新格式: { settings: {...}, changedKeys: [...], changedEntries: {...} } + // 从中提取逐 key 变化信息,保持原有的逐 key 同步逻辑 + const { changedEntries } = detail as { changedEntries?: Record }; + if (!changedEntries) return; + for (const [key, value] of Object.entries(changedEntries)) { if ( key === STORAGE_KEY_THEME || key === STORAGE_KEY_UI_THEME_LIGHT || @@ -174,7 +182,7 @@ export function useSettingsIpcSync({ key === STORAGE_KEY_COLOR ) { syncAppearanceFromStorage({ key, value }); - return; + continue; // 跳过当前 key 的后续检查,继续 for 循环 } if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') { const next = resolveSupportedLocale(value); @@ -333,14 +341,10 @@ export function useSettingsIpcSync({ if (key === STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS && typeof value === 'number') { setSshTransportIdleTtlMsState((prev) => (prev === value ? prev : value)); } - }); - return () => { - try { - unsubscribe?.(); - } catch { - // ignore - } + } // end for (changedEntries) }; + window.addEventListener("netcatty-settings-batch", handler); + return () => window.removeEventListener("netcatty-settings-batch", handler); }, [ enabled, applyIncomingCustomKeyBindings, diff --git a/application/state/useAISettingsState.ts b/application/state/useAISettingsState.ts index 45f22cd..975ad9b 100644 --- a/application/state/useAISettingsState.ts +++ b/application/state/useAISettingsState.ts @@ -293,12 +293,27 @@ export function useAISettingsState() { const handleLocalStateChanged = (event: Event) => { syncFromStorageKey((event as CustomEvent<{ key?: string }>).detail?.key ?? null); }; + // 远程浏览器改 AI 设置 → 主进程 broadcast → preload 更新 localStorage 并 dispatch 此事件 + // 这里遍历 changedEntries 中所有 AI_ 开头的 key,逐个刷新 React state + const handleBatchSync = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail || typeof detail !== 'object') return; + const entries = detail.changedEntries as Record | undefined; + if (!entries) return; + for (const key of Object.keys(entries)) { + if (key.startsWith('netcatty_ai_') || key.startsWith('netcatty.ai')) { + syncFromStorageKey(key); + } + } + }; window.addEventListener('storage', handleStorage); window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged); + window.addEventListener('netcatty-settings-batch', handleBatchSync); return () => { window.removeEventListener('storage', handleStorage); window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged); + window.removeEventListener('netcatty-settings-batch', handleBatchSync); }; }, []); diff --git a/application/state/useSettingsState.ts b/application/state/useSettingsState.ts index fac3bac..47f2951 100644 --- a/application/state/useSettingsState.ts +++ b/application/state/useSettingsState.ts @@ -2079,6 +2079,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable updateTerminalSetting, ]); + // 远程浏览器改了设置 → 主进程 broadcast → preload 更新 localStorage 并 dispatch 此事件 + // 这里监听并全量刷新 React state,解决 useState initializer 只跑一次的问题 + useEffect(() => { + const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__; + if (IS_REMOTE) return; // 远程浏览器不通过 preload,由 VaultViewLayout 处理 + const handler = (_e: Event) => { + rehydrateAllFromStorage(); + }; + window.addEventListener("netcatty-settings-batch", handler); + return () => window.removeEventListener("netcatty-settings-batch", handler); + }, [rehydrateAllFromStorage]); + return { theme, setTheme, diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts index 878a378..eee51eb 100644 --- a/application/state/useVaultState.ts +++ b/application/state/useVaultState.ts @@ -1374,7 +1374,7 @@ export const useVaultState = () => { // 并行 fetch 所有 vault 数据 const results = await Promise.allSettled([ - fetch(base("/api/devices")).then(r => r.json()), + fetch(base(`${apiBase}/hosts`)).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()), @@ -1390,22 +1390,11 @@ export const useVaultState = () => { ? (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); + // hosts(从 /api/vault/hosts 来,已脱敏无密码) + const hostsData = getOk(0, []); + if (Array.isArray(hostsData)) { + hostsRef.current = hostsData as Host[]; + setHosts(hostsData as Host[]); } // keys(已被 remoteHttpServer strip 敏感字段) const keysData = getOk(1, []); diff --git a/components/SettingsApplicationTab.tsx b/components/SettingsApplicationTab.tsx index 925a9f4..0c5da97 100644 --- a/components/SettingsApplicationTab.tsx +++ b/components/SettingsApplicationTab.tsx @@ -80,8 +80,8 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele }, [monitoringTitle, reportProductName]); // 远程 HTTP Server 状态 - const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null }>({ - running: false, port: 8848, token: null, + const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null; url: string | null }>({ + running: false, port: 8848, token: null, url: null, }); const [remoteLoading, setRemoteLoading] = useState(false); const [remotePortDraft, setRemotePortDraft] = useState("8848"); @@ -90,7 +90,12 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele try { const s = await (window as any).netcatty?.remoteHttp?.getStatus?.(); if (s) { - setRemoteStatus({ running: !!s.running, port: s.port || 8848, token: s.token || null }); + setRemoteStatus({ + running: !!s.running, + port: s.port || 8848, + token: s.token || null, + url: s.url || null, + }); setRemotePortDraft(String(s.port || 8848)); } } catch {} @@ -172,6 +177,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele running: !!checked, port: res.port || port, token: res.token || remoteStatus.token, + url: res.url || null, }); setRemotePortDraft(String(res.port || port)); if (checked) toast.success("远程访问已启动"); @@ -189,10 +195,9 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele }; const handleCopyUrl = async () => { - if (!remoteStatus.token) { toast.error("请先启用远程访问"); return; } - const url = `http://localhost:${remoteStatus.port}/dashboard?token=${remoteStatus.token}`; + if (!remoteStatus.url) { toast.error("请先启用远程访问"); return; } try { - await navigator.clipboard.writeText(url); + await navigator.clipboard.writeText(remoteStatus.url); toast.success("访问链接已复制"); } catch { toast.error("复制失败"); @@ -308,11 +313,11 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele (默认 8848,修改后需重启远程访问生效) - {remoteStatus.running && remoteStatus.token && ( + {remoteStatus.running && remoteStatus.url && (
- http://localhost:{remoteStatus.port}/dashboard?token={remoteStatus.token} + {remoteStatus.url}
@@ -536,6 +544,11 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock )} + {mountedTabs.has("monitoring") && ( + + + + )} {mountedTabs.has("plugins") && pluginRuntimeAvailable && ( diff --git a/components/inspection/AlertCenterPage.tsx b/components/inspection/AlertCenterPage.tsx index 6eba470..0a3a1f0 100644 --- a/components/inspection/AlertCenterPage.tsx +++ b/components/inspection/AlertCenterPage.tsx @@ -88,7 +88,7 @@ const STATUS_COLORS: Record = { closed: "text-muted-foreground", }; -export function AlertCenterPage() { +export function AlertCenterPage({ initialAlertId }: { initialAlertId?: string }) { const [alerts, setAlerts] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -97,6 +97,19 @@ export function AlertCenterPage() { const [noteDialogOpen, setNoteDialogOpen] = useState(false); const [noteText, setNoteText] = useState(""); + // 从工作台跳转时自动打开指定告警的详情弹窗 + const autoOpenedRef = useRef(false); + useEffect(() => { + if (!initialAlertId || autoOpenedRef.current) return; + if (alerts.length === 0) return; // 等数据加载完 + const target = alerts.find((a) => a.id === initialAlertId); + if (target) { + autoOpenedRef.current = true; + setSelectedAlert(target); + setDetailOpen(true); + } + }, [initialAlertId, alerts]); + // 防御:清洗告警里可能残留的 [object Object](老数据修复前写入的坏值) const _clean = (v: any): string => { if (v === null || v === undefined) return ""; diff --git a/components/inspection/ReportPage.tsx b/components/inspection/ReportPage.tsx index bd0edde..b6c47b3 100644 --- a/components/inspection/ReportPage.tsx +++ b/components/inspection/ReportPage.tsx @@ -80,35 +80,55 @@ function formatDuration(ms: number) { /** 从 vault 解密 + device extension 拿到所有设备的 SSH target * enabledDeviceIds 为空/undefined = 全部设备;有值则只返回列表内的 * 返回值第二个元素是完整 host 列表(用于 SchedulePanel 渲染设备多选) + * + * 远程浏览器模式下:从 HTTP /api/vault/hosts 读 hosts(已脱敏), + * 从 /api/devices 读 catalog_id。targets 不带真实凭据(远程跑不了 SSH)。 */ async function buildAllDeviceTargets(enabledDeviceIds?: string[]): Promise<{ targets: ReportDeviceTarget[]; allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }>; }> { + const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__; + const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || ""; + const targets: ReportDeviceTarget[] = []; const allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }> = []; - let hosts: HostAny[] = []; - try { - const raw = localStorage.getItem(STORAGE_KEY_HOSTS); - if (raw) hosts = await decryptHosts(JSON.parse(raw)); - } catch (_) {} - - let keys: KeyAny[] = []; - try { - const raw = localStorage.getItem(STORAGE_KEY_KEYS); - if (raw) keys = await decryptKeys(JSON.parse(raw)); - } catch (_) {} - + let hosts: any[] = []; let extensions: Record = {}; - try { - const raw = localStorage.getItem("netcatty_inspection_device_extension_v1"); - if (raw) extensions = JSON.parse(raw); - } catch (_) {} + + if (IS_REMOTE) { + // ── 远程浏览器:从 HTTP API 读 ── + try { + const url = `/api/vault/hosts?token=${encodeURIComponent(REMOTE_TOKEN)}`; + const res = await fetch(url); + const data = await res.json(); + hosts = Array.isArray(data?.data) ? data.data : []; + } catch (_) {} + // catalog_id 从 /api/devices 读 + try { + const url = `/api/devices?token=${encodeURIComponent(REMOTE_TOKEN)}`; + const res = await fetch(url); + const data = await res.json(); + for (const d of (data?.devices || [])) { + if (d?.host_id) extensions[d.host_id] = { catalog_id: d.catalog_id || d.catalog_type }; + } + } catch (_) {} + } else { + // ── Electron 本地:从 localStorage 读 ── + try { + const raw = localStorage.getItem(STORAGE_KEY_HOSTS); + if (raw) hosts = await decryptHosts(JSON.parse(raw)); + } catch (_) {} + try { + const raw = localStorage.getItem("netcatty_inspection_device_extension_v1"); + if (raw) extensions = JSON.parse(raw); + } catch (_) {} + } for (const host of hosts) { const ext = extensions[host.id]; - const catalogId = ext?.catalog_id || undefined; + const catalogId = ext?.catalog_id || host.catalog_id || undefined; const label = host.label || host.hostname; allHosts.push({ id: host.id, hostname: host.hostname, catalog_id: catalogId, label }); @@ -116,18 +136,14 @@ async function buildAllDeviceTargets(enabledDeviceIds?: string[]): Promise<{ // 过滤:enabledDeviceIds 有值且当前不在列表里 → 跳过 if (enabledDeviceIds && enabledDeviceIds.length > 0 && !enabledDeviceIds.includes(host.id)) continue; - const key = host.identityFileId - ? keys.find((k: KeyAny) => k.id === host.identityFileId) - : undefined; - targets.push({ device_id: host.id, hostname: host.hostname, port: host.port || 22, - username: host.username, + username: host.username || "", password: host.password, - privateKeyContent: key?.privateKeyContent, - passphrase: key?.passphrase, + privateKeyContent: host.privateKeyContent || undefined, + passphrase: host.passphrase, catalog_id: catalogId, }); } @@ -1237,23 +1253,35 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: { }) { const [local, setLocal] = useState(schedule); const [saving, setSaving] = useState(false); + // dirty 保护:用户在面板里编辑过 → 不要被外部 props.schedule 变化静默覆盖 + // (比如主进程广播 schedule 变更、其他组件触发 loadSchedule 等) + const dirtyRef = useRef(false); - useEffect(() => { setLocal(schedule); }, [schedule]); + useEffect(() => { + if (!dirtyRef.current) setLocal(schedule); + }, [schedule]); const handleSave = async () => { setSaving(true); await onSave(local); + dirtyRef.current = false; setSaving(false); }; + /** 编辑点统一入口:改 local + 标记 dirty,防止外部 props 覆盖未保存的本地编辑 */ + const edit = useCallback((updater: ReportSchedule | ((p: ReportSchedule) => ReportSchedule)) => { + dirtyRef.current = true; + setLocal(updater); + }, []); + // 设备多选 helpers // enabledDeviceIds 约定:null/undefined = 全部,[] = 一个都不选,[id1,id2] = 指定 const selectedAll = !local.enabledDeviceIds; // null / undefined → 全部 const toggleAllDevices = (selectAll: boolean) => { - setLocal(p => ({ ...p, enabledDeviceIds: selectAll ? null : [] })); + edit(p => ({ ...p, enabledDeviceIds: selectAll ? null : [] })); }; const toggleDevice = (deviceId: string) => { - setLocal(p => { + edit(p => { const cur = p.enabledDeviceIds; if (!cur) { // 当前是"全部" —— 取消这一台变成"除了这台" @@ -1291,7 +1319,11 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
按周期自动巡检
{ setLocal(p => ({ ...p, enabled: v })); onToggleScheduler(v); }} /> + onCheckedChange={(v) => { + edit(p => ({ ...p, enabled: v })); + // onToggleScheduler 直接写主进程,成功后清 dirty(async) + Promise.resolve(onToggleScheduler(v)).then(() => { dirtyRef.current = false; }); + }} /> {/* 巡检周期 */} @@ -1300,7 +1332,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
setLocal(p => ({ ...p, defaultIntervalHours: Number(e.target.value) || 1 }))} /> + onChange={(e) => edit(p => ({ ...p, defaultIntervalHours: Number(e.target.value) || 1 }))} /> 小时
@@ -1310,7 +1342,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
日报告触发时间
setLocal(p => ({ ...p, dailyReportTime: e.target.value }))} /> + onChange={(e) => edit(p => ({ ...p, dailyReportTime: e.target.value }))} /> {/* AI 汇总 */} @@ -1320,7 +1352,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
手动触发快照时生成 AI 四段式汇总
setLocal(p => ({ ...p, aiSummaryEnabled: v }))} /> + onCheckedChange={(v) => edit(p => ({ ...p, aiSummaryEnabled: v }))} /> diff --git a/components/monitoring/MonitoringTopologyView.tsx b/components/monitoring/MonitoringTopologyView.tsx index f968e21..38047b5 100644 --- a/components/monitoring/MonitoringTopologyView.tsx +++ b/components/monitoring/MonitoringTopologyView.tsx @@ -1,4 +1,4 @@ -/** +/** * 运行监控 - 3D 拓扑大屏 * * 移植自 ITYUNWEI TopologyView.vue @@ -503,47 +503,32 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { 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); - } - }); + // 2. 拓扑位置(layoutDevices 内部已经同步 XHR 加载了,这里不需要) // 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 给主进程 + // Electron 环境: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; - } + if (!nc?.inspection) return; // 不是 Electron 环境 - // 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)); + nc.inspection.saveVaultHosts(hosts).catch(() => {}); } 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 {} } @@ -605,6 +590,44 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { // 相机参数 —— 正常模式(45°俯视)和大屏模式(正俯视) const normalCameraRef = useRef({ pos: new THREE.Vector3(0, 55, 115), target: new THREE.Vector3(-20, -25, 20), minDist: 30, maxDist: 150, autoRotate: true, autoRotateSpeed: 0.4, fov: 60 }); + + // ===== 外部相机配置(Settings 页面驱动)===== + const CAMERA_CONFIG_KEY = "monitoring-camera-config"; + const CAMERA_TARGET = new THREE.Vector3(-20, -25, 20); // OrbitControls 的旋转中心(与 init scene 一致) + + interface ICameraConfig { + elevation: number; azimuth: number; distance: number; + fov: number; autoRotate: boolean; autoRotateSpeed: number; + } + // 从球坐标(elevation 仰角、azimuth 方位角、distance 距离)算 camera.position(y 向上的右手系) + const computePosFromCfg = (cfg: ICameraConfig, target: THREE.Vector3 = CAMERA_TARGET): THREE.Vector3 => { + const el = (cfg.elevation * Math.PI) / 180; + const az = (cfg.azimuth * Math.PI) / 180; + const d = cfg.distance; + const offset = new THREE.Vector3( + d * Math.cos(el) * Math.cos(az), + d * Math.sin(el), + d * Math.cos(el) * Math.sin(az), + ); + return offset.add(target); + }; + const readCameraCfg = (): ICameraConfig | null => { + try { + const raw = localStorage.getItem(CAMERA_CONFIG_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { return null; } + }; + // 外部相机配置的过渡锁:避免用户手动拖拽相机时被外部事件反写 + const externalCameraCfgAppliedRef = useRef(false); + + // 地图底图热替换用的 ref(init scene useEffect 外也能访问 → dispose / rebuild) + const mapPlaneRef = useRef(null); + const glowPlaneRef = useRef(null); + const mapTextureRef = useRef(null); + const gridOnMapRef = useRef(null); + const gridOutsideRef = useRef(null); + // 相机平滑过渡动画(Vector3.lerp 逐帧插值) interface CameraAnim { fromPos: THREE.Vector3; @@ -1382,13 +1405,19 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { scene.fog = new THREE.Fog(0xcfe2f3, 120, 280); sceneRef.current = scene; + // 【根治跳帧 Bug】Three.js 初始化**第一帧**就读 Settings 里配置的相机参数 + // 这样相机一出生就是对的位置,不会先渲染硬编码位置再被 useEffect 异步改掉 + const bootCfg = readCameraCfg(); + const bootFov = bootCfg?.fov ?? 60; + const bootPos = bootCfg ? computePosFromCfg(bootCfg) : new THREE.Vector3(0, 55, 115); + const camera = new THREE.PerspectiveCamera( - 60, + bootFov, container.clientWidth / container.clientHeight, 0.1, 500 ); - camera.position.set(0, 55, 115); + camera.position.copy(bootPos); cameraRef.current = camera; // 复用全局单例 WebGLRenderer —— 整个应用只创建一个 WebGL context @@ -1485,12 +1514,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { controls.enableZoom = true; controls.target.set(-20, -25, 20); // 往上抬 target,让顶部露出更多蓝天 controls.update(); - controls.minDistance = 30; - controls.maxDistance = 150; + controls.minDistance = bootCfg ? Math.max(20, bootCfg.distance - 80) : 30; + controls.maxDistance = bootCfg ? Math.min(350, bootCfg.distance + 80) : 150; controls.maxPolarAngle = Math.PI / 2 - 0.1; controls.minPolarAngle = 0; - controls.autoRotate = true; - controls.autoRotateSpeed = 0.4; + controls.autoRotate = bootCfg?.autoRotate ?? true; + controls.autoRotateSpeed = bootCfg?.autoRotateSpeed ?? 0.4; controlsRef.current = controls; // TransformControls 拖拽 @@ -1541,12 +1570,13 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { new THREE.Plane(new THREE.Vector3(0, 0, -1), h / 2), ]; - // 地图外网格(深蓝) + // 地图外网格(深蓝)——先按默认正方形创建,地图纹理加载完再按真实 aspect 更新裁剪平面 const gridOutside = new THREE.GridHelper(gridSize, gridDiv, 0x3d6aa0, 0x1e3a60); gridOutside.position.y = -0.45; const outsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate()); (gridOutside.material as THREE.Material).clippingPlanes = outsidePlanes; scene.add(gridOutside); + gridOutsideRef.current = gridOutside; // 地图上网格(极浅半透明) const gridOnMap = new THREE.GridHelper(gridSize, gridDiv, 0x0d1f30, 0x060f1a); @@ -1555,6 +1585,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { onMapMat.transparent = true; onMapMat.opacity = 0.18; scene.add(gridOnMap); + gridOnMapRef.current = gridOnMap; // Glow ring const ringGeo = new THREE.RingGeometry(25, 30, 64); @@ -1623,100 +1654,161 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { sky.name = 'SkyDome'; scene.add(sky); - // ===== ditu.png 地图纹理 + 边缘发光晕 ===== - const texLoader = new THREE.TextureLoader(); - texLoader.setCrossOrigin('anonymous'); - texLoader.load( - '/ditu.png', - (texture) => { - texture.colorSpace = THREE.SRGBColorSpace; - const mapAspect = texture.image.width / texture.image.height; - mapW = gridSize; - mapH = gridSize / mapAspect; + // ===== 地图纹理(用户上传 → data URL,或默认 /ditu.png)===== + // rebuildMapFromTexture:dispose 旧 mesh/material/texture → 重建地图层级 + // 初始化调一次,热替换时再调一次(从外部事件监听调用) + const _sceneForHot = scene; // 闭包内缓存,给外部 hot-rebuild 用 + const _gridSizeForHot = gridSize; - // 地图发光底层(比地图稍大,形成发光边缘) - const glowW = mapW + 6; - const glowH = mapH + 6; - const glowGeo = new THREE.PlaneGeometry(glowW, glowH); - const glowMat = new THREE.ShaderMaterial({ - uniforms: { - uMap: { value: texture }, - uColor: { value: new THREE.Color('#38bdf8') }, - uFade: { value: 0.12 }, - }, - vertexShader: ` - varying vec2 vUv; - void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); } - `, - fragmentShader: ` - varying vec2 vUv; - uniform sampler2D uMap; - uniform vec3 uColor; - uniform float uFade; - void main(){ - vec4 c = texture2D(uMap, vUv); - float dx = min(vUv.x, 1.0 - vUv.x) * 2.0; - float dy = min(vUv.y, 1.0 - vUv.y) * 2.0; - float edge = min(dx, dy); - float glow = 1.0 - smoothstep(0.0, uFade, edge); - gl_FragColor = vec4(uColor, glow * 0.5); - } - `, - transparent: true, - side: THREE.DoubleSide, - depthWrite: false, - }); - const glowPlane = new THREE.Mesh(glowGeo, glowMat); - glowPlane.rotation.x = -Math.PI / 2; - glowPlane.position.y = -0.58; - scene.add(glowPlane); - - const mapGeo = new THREE.PlaneGeometry(mapW, mapH); - const mapMat = new THREE.ShaderMaterial({ - uniforms: { - uMap: { value: texture }, - uOpacity: { value: 0.85 }, - uFade: { value: 0.08 }, - }, - vertexShader: ` - varying vec2 vUv; - void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); } - `, - fragmentShader: ` - varying vec2 vUv; - uniform sampler2D uMap; - uniform float uOpacity; - uniform float uFade; - void main(){ - vec4 c = texture2D(uMap, vUv); - float dx = min(vUv.x, 1.0 - vUv.x) * 2.0; - float dy = min(vUv.y, 1.0 - vUv.y) * 2.0; - float edge = min(dx, dy); - float a = smoothstep(0.0, uFade, edge); - gl_FragColor = vec4(c.rgb, c.a * a * uOpacity); - } - `, - transparent: true, - side: THREE.DoubleSide, - depthWrite: false, - }); - const mapPlane = new THREE.Mesh(mapGeo, mapMat); - mapPlane.rotation.x = -Math.PI / 2; - mapPlane.position.y = -0.6; - scene.add(mapPlane); - - // 地图加载完更新网格裁剪平面 - const newOnMapPlanes = makeClipPlanes(mapW, mapH); - ;(gridOnMap.material as THREE.Material).clippingPlanes = newOnMapPlanes; - const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate()); - ;(gridOutside.material as THREE.Material).clippingPlanes = newOutsidePlanes; - }, - undefined, - () => { - // 地图加载失败,静默跳过 - console.warn('[Monitoring] ditu.png load failed, skipping map texture'); + function rebuildMapFromTexture(loadedTexture: THREE.Texture) { + // dispose 旧的 + if (mapPlaneRef.current) { + _sceneForHot.remove(mapPlaneRef.current); + mapPlaneRef.current.geometry.dispose(); + disposeMatTextures(mapPlaneRef.current.material); + mapPlaneRef.current = null; } - ); + if (glowPlaneRef.current) { + _sceneForHot.remove(glowPlaneRef.current); + glowPlaneRef.current.geometry.dispose(); + disposeMatTextures(glowPlaneRef.current.material); + glowPlaneRef.current = null; + } + if (mapTextureRef.current) { + mapTextureRef.current.dispose(); + mapTextureRef.current = null; + } + + loadedTexture.colorSpace = THREE.SRGBColorSpace; + mapTextureRef.current = loadedTexture; + + const img: any = loadedTexture.image ?? { width: 1, height: 1 }; + const mapAspect = (img.width || 1) / Math.max(1, img.height || 1); + mapW = _gridSizeForHot; + mapH = _gridSizeForHot / Math.max(0.01, mapAspect); + + // 发光底层 + const glowW = mapW + 6; + const glowH = mapH + 6; + const glowGeo = new THREE.PlaneGeometry(glowW, glowH); + const glowMat = new THREE.ShaderMaterial({ + uniforms: { + uMap: { value: loadedTexture }, + uColor: { value: new THREE.Color('#38bdf8') }, + uFade: { value: 0.12 }, + }, + vertexShader: ` + varying vec2 vUv; + void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); } + `, + fragmentShader: ` + varying vec2 vUv; + uniform sampler2D uMap; + uniform vec3 uColor; + uniform float uFade; + void main(){ + vec4 c = texture2D(uMap, vUv); + float dx = min(vUv.x, 1.0 - vUv.x) * 2.0; + float dy = min(vUv.y, 1.0 - vUv.y) * 2.0; + float edge = min(dx, dy); + float glow = 1.0 - smoothstep(0.0, uFade, edge); + gl_FragColor = vec4(uColor, glow * 0.5); + } + `, + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + }); + const glowPlane = new THREE.Mesh(glowGeo, glowMat); + glowPlane.rotation.x = -Math.PI / 2; + glowPlane.position.y = -0.58; + _sceneForHot.add(glowPlane); + glowPlaneRef.current = glowPlane; + + const mapGeo = new THREE.PlaneGeometry(mapW, mapH); + const mapMat = new THREE.ShaderMaterial({ + uniforms: { + uMap: { value: loadedTexture }, + uOpacity: { value: 0.85 }, + uFade: { value: 0.08 }, + }, + vertexShader: ` + varying vec2 vUv; + void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); } + `, + fragmentShader: ` + varying vec2 vUv; + uniform sampler2D uMap; + uniform float uOpacity; + uniform float uFade; + void main(){ + vec4 c = texture2D(uMap, vUv); + float dx = min(vUv.x, 1.0 - vUv.x) * 2.0; + float dy = min(vUv.y, 1.0 - vUv.y) * 2.0; + float edge = min(dx, dy); + float a = smoothstep(0.0, uFade, edge); + gl_FragColor = vec4(c.rgb, c.a * a * uOpacity); + } + `, + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + }); + const mapPlane = new THREE.Mesh(mapGeo, mapMat); + mapPlane.rotation.x = -Math.PI / 2; + mapPlane.position.y = -0.6; + _sceneForHot.add(mapPlane); + mapPlaneRef.current = mapPlane; + + // 更新网格裁剪平面(按真实 aspect) + const newOnMapPlanes = makeClipPlanes(mapW, mapH); + if (gridOnMapRef.current) { + (gridOnMapRef.current.material as THREE.Material).clippingPlanes = newOnMapPlanes; + } + const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate()); + if (gridOutsideRef.current) { + (gridOutsideRef.current.material as THREE.Material).clippingPlanes = newOutsidePlanes; + } + } + + function loadMapByUrl(url: string) { + const loader = new THREE.TextureLoader(); + loader.setCrossOrigin('anonymous'); + loader.load( + url, + (texture) => rebuildMapFromTexture(texture), + undefined, + () => { + if (url !== '/ditu.png') { + console.warn('[Monitoring] 用户地图加载失败,fallback /ditu.png'); + loader.load('/ditu.png', rebuildMapFromTexture); + } else { + console.warn('[Monitoring] ditu.png load failed, skipping map texture'); + } + } + ); + } + + // 初始化:优先用户上传底图 → fallback /ditu.png + void (async () => { + let targetUrl = '/ditu.png'; + try { + const nc = (window as any).netcatty; + if (nc?.inspection?.getMapBgData) { + const res = await nc.inspection.getMapBgData(); + if (res?.ok && res.dataUrl) { + targetUrl = res.dataUrl; + console.info('[Monitoring] 使用用户上传底图:', res.filename); + } + } + } catch (e) { + console.warn('[Monitoring] getMapBgData 失败,使用默认 ditu.png:', e); + } + loadMapByUrl(targetUrl); + })(); + + // 热替换暴露给外部(useEffect 里监听事件时调用) + (scene as any).__rebuildMapByUrl = loadMapByUrl; // 设备/连线创建已移到下方独立的 useEffect(依赖 [devices, links]) @@ -2232,6 +2324,149 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 只在 mount/unmount 时跑,renderer 全局唯一 + // ===== 外部相机配置监听(Settings 页面驱动)===== + // 主路径:主进程 IPC 广播(inspection:camera-config-updated),最可靠 + // 兜底:storage 事件(浏览器远程访问模式) + useEffect(() => { + console.info("[MonitoringTopologyView] camera cfg listener mounted"); + + let lastAppliedTs = 0; // debounce:连续触发合并 + let ipcUnsubscribe: (() => void) | null = null; + + const applyCameraCfg = (cfg: ICameraConfig) => { + const camera = cameraRef.current; + const controls = controlsRef.current; + if (!camera || !controls) { + console.warn("[MonitoringTopologyView] applyCameraCfg: camera/controls not ready yet"); + return; + } + + // debounce:至少隔 80ms + const now = performance.now(); + if (now - lastAppliedTs < 80) return; + lastAppliedTs = now; + + // 先更新 OrbitControls 的距离/FOV/自动旋转 + const newMinDist = Math.max(20, cfg.distance - 80); + const newMaxDist = Math.min(350, cfg.distance + 80); + controls.minDistance = newMinDist; + controls.maxDistance = newMaxDist; + controls.autoRotate = !!cfg.autoRotate; + controls.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4; + + const target = CAMERA_TARGET.clone(); + const newPos = computePosFromCfg(cfg, target); + const newFov = cfg.fov ?? 60; + + console.info("[MonitoringTopologyView] applyCameraCfg", { + elevation: cfg.elevation, azimuth: cfg.azimuth, distance: cfg.distance, + fov: newFov, autoRotate: cfg.autoRotate, + newPos: newPos.toArray().map((v) => Number(v.toFixed(2))), + curPos: camera.position.toArray().map((v) => Number(v.toFixed(2))), + }); + + cameraAnimRef.current = { + fromPos: camera.position.clone(), + toPos: newPos, + fromTarget: controls.target.clone(), + toTarget: target, + fromFov: camera.fov, + toFov: newFov, + startMs: performance.now(), + durationMs: 400, + }; + normalCameraRef.current.pos.copy(newPos); + normalCameraRef.current.target.copy(target); + normalCameraRef.current.fov = newFov; + normalCameraRef.current.autoRotate = !!cfg.autoRotate; + normalCameraRef.current.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4; + externalCameraCfgAppliedRef.current = true; + }; + + // mount 时应用一次初始配置(用户可能在 Settings 调了,然后才打开监控大屏) + const initialCfg = readCameraCfg(); + if (initialCfg) { + console.info("[MonitoringTopologyView] mount: found initial camera cfg"); + const t = setTimeout(() => applyCameraCfg(initialCfg), 800); + // cleanup 里会清 timeout,但先记一下让 ipcUnsubscribe 覆盖 + } else { + console.info("[MonitoringTopologyView] mount: no initial camera cfg (use Three.js defaults)"); + } + + // 主路径:preload 暴露的 IPC 事件(Electron 环境) + try { + const nc = (window as any).netcatty; + if (nc?.inspection?.onCameraConfigUpdated) { + ipcUnsubscribe = nc.inspection.onCameraConfigUpdated((cfg: ICameraConfig) => { + console.info("[MonitoringTopologyView] IPC camera-config-updated", cfg); + if (cfg) applyCameraCfg(cfg); + }); + } + } catch (e) { + console.warn("[MonitoringTopologyView] IPC listener unavailable:", e); + } + + // 兜底:storage 事件(跨窗口/跨标签)+ 同窗口 CustomEvent(Settings 页面触发) + const onStorage = (e: StorageEvent) => { + if (e.key === CAMERA_CONFIG_KEY) { + const cfg = e.newValue ? JSON.parse(e.newValue) : null; + if (cfg) applyCameraCfg(cfg); + } + }; + const onCustomEvent = (e: Event) => { + try { + const cfg = localStorage.getItem(CAMERA_CONFIG_KEY); + if (cfg) applyCameraCfg(JSON.parse(cfg)); + } catch {} + }; + window.addEventListener("storage", onStorage); + window.addEventListener("monitoring-camera-changed", onCustomEvent); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("monitoring-camera-changed", onCustomEvent); + if (ipcUnsubscribe) ipcUnsubscribe(); + }; + }, []); + + // ===== 地图底图热替换:Settings 里改了地图 → monitoring 端实时重建 ===== + useEffect(() => { + const scene: any = sceneRef.current; + if (!scene) return; + + const applyMapBg = async () => { + let url = '/ditu.png'; + try { + const nc = (window as any).netcatty; + if (nc?.inspection?.getMapBgData) { + const res = await nc.inspection.getMapBgData(); + if (res?.ok && res.dataUrl) { + url = res.dataUrl; + } + } + } catch (e) { /* ignore — 浏览器远程环境无此 API */ } + const rebuilder = scene.__rebuildMapByUrl; + if (typeof rebuilder === 'function') rebuilder(url); + }; + + const onCustomEvent = () => { void applyMapBg(); }; + const onStorageEvent = (e: StorageEvent) => { + if (e.key && /map_bg|monitoring.*bg/i.test(e.key)) { + void applyMapBg(); + } + }; + + // SettingsMonitoringTab 切换后 dispatchEvent 触发(同 window) + window.addEventListener('monitoring-map-bg-changed', onCustomEvent); + // localStorage storage 事件(跨 window 同步;Settings 独立 window 时监听) + window.addEventListener('storage', onStorageEvent); + + return () => { + window.removeEventListener('monitoring-map-bg-changed', onCustomEvent); + window.removeEventListener('storage', onStorageEvent); + }; + }, []); + // ===== 设备/连线创建:每次 devices/links 变化重建 scene 内容,不重建 renderer ===== useEffect(() => { const scene = sceneRef.current; @@ -2546,12 +2781,36 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { }, [typeDistribution, monitoring.alarmTrend]); const resetView = useCallback(() => { - if (cameraRef.current && controlsRef.current) { - cameraRef.current.position.set(0, 80, 125); - controlsRef.current.target.set(-20, -40, 20); - controlsRef.current.autoRotate = true; - controlsRef.current.update(); + if (!cameraRef.current || !controlsRef.current) return; + const camera = cameraRef.current; + const controls = controlsRef.current; + + // 优先用 Settings 里配置好的相机参数重置;没有才 fallback 硬编码默认 + const cfg = readCameraCfg(); + if (cfg) { + const target = CAMERA_TARGET.clone(); + const newPos = computePosFromCfg(cfg, target); + camera.position.copy(newPos); + controls.target.copy(target); + const newMin = Math.max(20, cfg.distance - 80); + const newMax = Math.min(350, cfg.distance + 80); + controls.minDistance = newMin; + controls.maxDistance = newMax; + controls.autoRotate = !!cfg.autoRotate; + controls.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4; + camera.fov = cfg.fov ?? 60; + camera.updateProjectionMatrix(); + normalCameraRef.current.pos.copy(newPos); + normalCameraRef.current.target.copy(target); + normalCameraRef.current.fov = cfg.fov ?? 60; + normalCameraRef.current.autoRotate = !!cfg.autoRotate; + normalCameraRef.current.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4; + } else { + camera.position.set(0, 80, 125); + controls.target.set(-20, -40, 20); + controls.autoRotate = true; } + controls.update(); }, []); const refreshData = useCallback(() => { @@ -2769,6 +3028,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) { background: 'linear-gradient(to right, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)', marginRight: '12px', boxShadow: '0 0 6px rgba(74,155,255,0.5)', + position: 'relative', + top: '14px', }} />

diff --git a/components/settings/tabs/SettingsMonitoringTab.tsx b/components/settings/tabs/SettingsMonitoringTab.tsx new file mode 100644 index 0000000..f40c125 --- /dev/null +++ b/components/settings/tabs/SettingsMonitoringTab.tsx @@ -0,0 +1,474 @@ +/** + * Settings Monitoring Tab —— 监控大屏地图底图 + 相机参数配置 + * + * 底图:上传 png/jpg/webp 到 userData/monitoring/maps/,拓扑图热替换。 + * 相机:倾斜角 / 距离 / FOV / 方位角 / 自动旋转 写 localStorage, + * MonitoringTopologyView 通过 storage 事件监听 + cameraAnimRef 平滑过渡。 + */ + +import { Image, Upload, Trash2, Check, RotateCcw, Camera, Maximize2, ZoomIn, AlertTriangle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../../ui/button"; +import { ScrollArea } from "../../ui/scroll-area"; +import { cn } from "../../../lib/utils"; +import { toast } from "../../ui/toast"; +import { SectionHeader, SettingCard, SettingsTabContent } from "../settings-ui"; + +const ALLOWED_EXTS = [".png", ".jpg", ".jpeg", ".webp"]; +const ALLOWED_ACCEPT = "image/png,image/jpeg,image/jpg,image/webp"; +const MAX_BYTES = 20 * 1024 * 1024; + +const BG_CHANGED_EVENT = "monitoring-map-bg-changed"; +const CAMERA_CONFIG_KEY = "monitoring-camera-config"; + +interface MapBgEntry { + filename: string; + size: number; + mtimeMs: number; +} + +interface CameraConfig { + elevation: number; + azimuth: number; + distance: number; + fov: number; + autoRotate: boolean; + autoRotateSpeed: number; +} + +const DEFAULT_CAMERA: CameraConfig = { + elevation: 40, + azimuth: 45, + distance: 130, + fov: 60, + autoRotate: true, + autoRotateSpeed: 0.4, +}; + +function formatSize(bytes: number): string { + if (!bytes || bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} +function formatDate(ms: number): string { + if (!ms) return ""; + try { return new Date(ms).toLocaleString(); } catch { return ""; } +} + +function emitBgChanged() { + try { window.dispatchEvent(new Event(BG_CHANGED_EVENT)); } catch {} +} +function emitCameraChanged() { + try { window.dispatchEvent(new Event("monitoring-camera-changed")); } catch {} +} + +function getInspectionApi() { + const nc = typeof window !== "undefined" ? (window as any).netcatty : null; + return nc?.inspection ?? null; +} + +function readCameraConfig(): CameraConfig { + try { + const raw = localStorage.getItem(CAMERA_CONFIG_KEY); + if (raw) { + const v = JSON.parse(raw); + return { ...DEFAULT_CAMERA, ...v }; + } + } catch {} + return { ...DEFAULT_CAMERA }; +} +function writeCameraConfig(cfg: CameraConfig) { + try { + localStorage.setItem(CAMERA_CONFIG_KEY, JSON.stringify(cfg)); + } catch {} +} + +function SliderRow({ + label, value, min, max, step, unit, onChange, +}: { + label: React.ReactNode; value: number; min: number; max: number; step?: number; unit?: string; + onChange: (v: number) => void; +}) { + return ( +
+
+ {label} + + {value}{unit || ""} + +
+ onChange(Number(e.target.value))} + className="w-full accent-primary" + /> +
+ ); +} + +export function SettingsMonitoringTab() { + const api = getInspectionApi(); + const fileInputRef = useRef(null); + + const [entries, setEntries] = useState([]); + const [selected, setSelected] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [uploading, setUploading] = useState(false); + + const [camera, setCamera] = useState(() => readCameraConfig()); + + const isAvailable = !!api; + + const refresh = useCallback(async () => { + if (!api) return; + let list: MapBgEntry[] = []; + let previewDataUrl: string | null = null; + let previewFilename: string | null = null; + + try { + const raw = (await api.listMapBgs()) as MapBgEntry[]; + list = Array.isArray(raw) ? raw : []; + } catch (e) { + console.warn("[SettingsMonitoringTab] listMapBgs failed:", e); + } + + try { + const res = await api.getMapBgData(); + if (res?.ok && typeof res.dataUrl === "string" && res.dataUrl.length > 0) { + previewDataUrl = res.dataUrl; + previewFilename = res.filename || null; + } + } catch (e) { + console.warn("[SettingsMonitoringTab] getMapBgData failed:", e); + } + + setEntries(list); + setSelected(previewFilename); + setPreviewUrl(previewDataUrl); + }, [api]); + + useEffect(() => { void refresh(); }, [refresh]); + + // 相机参数任何变化 → 写 localStorage + debounced 走主进程 IPC 广播 + useEffect(() => { + writeCameraConfig(camera); + emitCameraChanged(); + + // debounce 后调 IPC:Slider 拖动很频繁,主进程广播需要节流 + if (!api?.setCameraConfig) return; + const t = setTimeout(() => { + api.setCameraConfig(camera).catch(() => {}); + }, 150); + return () => clearTimeout(t); + }, [camera, api]); + + const handleFiles = useCallback(async (files: FileList | null) => { + if (!api || !files || files.length === 0) return; + setUploading(true); + try { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const ext = "." + file.name.split(".").pop()?.toLowerCase(); + if (!ALLOWED_EXTS.includes(ext)) { + toast.warning(`跳过不支持的文件 ${file.name}`); + continue; + } + if (file.size > MAX_BYTES) { + toast.warning(`${file.name} 超过 20MB 上限`); + continue; + } + const dataUrl: string = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("FileReader failed")); + reader.readAsDataURL(file); + }); + const base64 = dataUrl.split(",", 2)[1] || ""; + const res = await api.uploadMapBg(file.name, base64); + if (res?.ok) { + toast.success("上传成功"); + const sel = await api.selectMapBg(res.filename); + if (sel?.ok) emitBgChanged(); + } else { + toast.error(`上传失败: ${res?.error || "未知"}`); + } + } + } catch (e: any) { + console.error("[SettingsMonitoringTab] upload error:", e); + toast.error(`上传失败: ${e?.message || String(e)}`); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + void refresh(); + } + }, [api, refresh]); + + const handleSelect = useCallback(async (filename: string) => { + if (!api) return; + const res = await api.selectMapBg(filename); + if (res?.ok) { emitBgChanged(); await refresh(); } + else toast.error(`切换失败: ${res?.error || ""}`); + }, [api, refresh]); + + const handleDelete = useCallback(async (filename: string) => { + if (!api) return; + const res = await api.deleteMapBg(filename); + if (res?.ok) { emitBgChanged(); await refresh(); } + else toast.error(`删除失败: ${res?.error || ""}`); + }, [api, refresh]); + + const handleReset = useCallback(async () => { + if (!api) return; + const res = await api.resetMapBg(); + if (res?.ok) { + emitBgChanged(); + toast.success("已恢复默认地图"); + setSelected(null); + setPreviewUrl(null); + await refresh(); + } else toast.error(`恢复默认失败: ${res?.error || ""}`); + }, [api, refresh]); + + const handleResetCamera = useCallback(() => { + setCamera({ ...DEFAULT_CAMERA }); + toast.success("已恢复默认相机参数"); + }, []); + + const sortedEntries = useMemo( + () => [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs), + [entries], + ); + + return ( + + + + {!isAvailable && ( + +
+ +
+ 底图上传仅在 Electron 本机可用。远程浏览器版本暂不支持。 +
+
+
+ )} + + {isAvailable && ( + <> + {/* 当前底图 + 紧凑上传 */} + +
+
+ + 底图 + + ({selected ? "自定义" : "内置 /ditu.png"}) + +
+
+ void handleFiles(e.target.files)} + /> + + +
+
+
+ {previewUrl ? ( +
+ current map bg +
+ {selected} +
+
+ ) : ( +
+ 使用内置 /ditu.png 默认底图 +
+ )} +
+
+ + {/* 已上传底图列表 */} + +
+
+ 已上传底图 + + {sortedEntries.length} + +
+ +
+ + {sortedEntries.length === 0 ? ( +
+ 尚未上传自定义底图 +
+ ) : ( +
    + {sortedEntries.map((entry) => { + const isActive = selected === entry.filename; + return ( +
  • +
    +
    + {entry.filename} +
    +
    + {formatSize(entry.size)} · {formatDate(entry.mtimeMs)} +
    +
    + {isActive ? ( + + 当前 + + ) : ( + + )} + +
  • + ); + })} +
+ )} +
+
+ + )} + + {/* 相机视角配置(任何环境都渲染 —— 不依赖 inspection API) */} + +
+
+ + 拓扑相机视角 +
+ +
+ +
+ 倾斜角(俯视 → 侧视)} + value={camera.elevation} + min={15} + max={85} + onChange={(v) => setCamera((c) => ({ ...c, elevation: v }))} + unit="°" + /> + 相机距离(近 → 远)} + value={camera.distance} + min={30} + max={220} + onChange={(v) => setCamera((c) => ({ ...c, distance: v }))} + /> + setCamera((c) => ({ ...c, azimuth: v }))} + unit="°" + /> + setCamera((c) => ({ ...c, fov: v }))} + unit="°" + /> +
+ +
+ +
+ 旋转速度 + setCamera((c) => ({ ...c, autoRotateSpeed: Number(e.target.value) }))} + className="w-28 accent-primary" + /> + {camera.autoRotateSpeed.toFixed(2)} +
+
+
+
+ ); +} + +export default SettingsMonitoringTab; diff --git a/components/vault/VaultViewLayout.tsx b/components/vault/VaultViewLayout.tsx index b117876..c858678 100644 --- a/components/vault/VaultViewLayout.tsx +++ b/components/vault/VaultViewLayout.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useCallback, lazy, Suspense, useState, useEffect } from "react"; -import { AlertTriangle, Bell, Activity, Network, FileText } from "lucide-react"; +import { AlertTriangle, Bell, Activity, Network, FileText, BarChart3 } from "lucide-react"; +import { WorkbenchView } from "./WorkbenchView"; import { deleteVaultKey } from "../../application/defaultKeyPassphrases"; import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit"; import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host"; @@ -22,6 +23,7 @@ import { } from "./VaultPageHeader"; import { useConnectionLogsStore } from "../../application/state/connectionLogsStore"; import { useNotesStore } from "../../application/state/notesStore"; +import { useSettingsState } from "../../application/state/useSettingsState"; import { LazyLoadBoundary } from "../ui/lazy-load-boundary"; import { toast } from "../ui/toast"; import { AppWordmark } from "../AppWordmark"; @@ -35,6 +37,8 @@ const InspectionCenterPage = lazy(() => import("../inspection/InspectionCenterPa const MonitoringTopologyView = lazy(() => import("../monitoring/MonitoringTopologyView")); // 巡检报告 const ReportPage = lazy(() => import("../inspection/ReportPage")); +// 设置页面(远程访问降级用) +const SettingsPage = lazy(() => import("../SettingsPage")); type VaultViewLayoutContext = Record; @@ -111,10 +115,12 @@ function VaultConnectionLogsSection({ ConnectionLogsManager, hosts, onOpenLogView, + initialLogId, }: { ConnectionLogsManager: React.ComponentType; hosts: any[]; onOpenLogView: (log: any) => void; + initialLogId?: string; }) { const { connectionLogs, @@ -122,6 +128,14 @@ function VaultConnectionLogsSection({ deleteConnectionLog, clearUnsavedConnectionLogs, } = useConnectionLogsStore(); + + // 从工作台跳转时自动打开指定 log 的详情 + React.useEffect(() => { + if (!initialLogId) return; + const log = connectionLogs.find((l: any) => l.id === initialLogId); + if (log) onOpenLogView(log); + }, [initialLogId, connectionLogs, onOpenLogView]); + return ( { + if (!IS_REMOTE) return; + const TOKEN = (window as any).__REMOTE_TOKEN__; + const API_BASE = `${window.location.origin}`; + + (globalThis as any).__NETCATTY_SETTINGS_SYNC__ = (key: string, value: string | null) => { + fetch(`${API_BASE}/api/settings?token=${encodeURIComponent(TOKEN)}`, { + method: value === null ? "DELETE" : "POST", + headers: { "Content-Type": "application/json" }, + body: value === null ? JSON.stringify({ keys: [key] }) : JSON.stringify({ [key]: value }), + }).catch(() => {}); + }; + + return () => { + if ((globalThis as any).__NETCATTY_SETTINGS_SYNC__) { + (globalThis as any).__NETCATTY_SETTINGS_SYNC__ = null; + } + }; + }, [IS_REMOTE]); + + const remoteSettings = useSettingsState(); + + // 工作台跳转时带过来的预选中项(alertId / logId / hostId) + const pendingSelectionRef = React.useRef<{ alertId?: string; logId?: string; hostId?: string } | null>(null); + // 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件 const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh'); @@ -444,6 +486,16 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) { ), [displayedGroups, viewMode, visibleTreeGroupPaths], ); + // 当切到 hosts section 且带了 hostId,自动选中 + React.useEffect(() => { + if (currentSection !== "hosts") return; + const pending = pendingSelectionRef.current; + if (pending?.hostId) { + setSelectedHostIds(new Set([pending.hostId])); + pendingSelectionRef.current = null; // 消费掉 + } + }, [currentSection, setSelectedHostIds]); + React.useEffect(() => { setSelectedGroupPaths((current: Set) => { const next = retainVisibleVaultGroupSelection(current, visibleSelectableGroupPaths); @@ -457,6 +509,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) { resizeAriaLabel: t("vault.panel.resizeWidth"), }; const [isSidebarResizing, setIsSidebarResizing] = React.useState(false); + const [showRemoteSettings, setShowRemoteSettings] = React.useState(false); const keyListRef = React.useRef(keys); keyListRef.current = keys; const newHostActionsRef = React.useRef(null); @@ -600,6 +653,30 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
+ {/* 工作台 */} + + + setCurrentSection("workbench")} + > + + {!sidebarCollapsed && "工作台"} + + + {sidebarCollapsed && ( + 工作台 + )} + + { + if (IS_REMOTE) { + setShowRemoteSettings(true); + } else { + onOpenSettings(); + } + }} > {!sidebarCollapsed && t("common.settings")} @@ -1383,6 +1466,27 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) { }} /> + {/* 工作台 */} + {currentSection === "workbench" && ( +
+ { + if (opts) pendingSelectionRef.current = opts; + setCurrentSection(section); + }} + onConnectHost={(hostId: string) => { + const h = hosts?.find((x: any) => x.id === hostId); + if (h) handleHostConnect(h); + }} + /> +
+ )} + {currentSection === "snippets" && ( }> @@ -1556,6 +1660,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) { ConnectionLogsManager={LazyConnectionLogsManager} hosts={hosts} onOpenLogView={onOpenLogView} + initialLogId={pendingSelectionRef.current?.logId ?? undefined} /> @@ -1580,7 +1685,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) { }>
- +
@@ -2028,6 +2133,25 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) { onUpdateHosts([...hosts, host]); }} /> + + {/* Remote Settings Dialog (远程访问设置面板) */} + {IS_REMOTE && ( + + + + {t("common.settings")} + +
+ 加载设置中...
}> + + +
+ + + )} ); } diff --git a/components/vault/WorkbenchView.tsx b/components/vault/WorkbenchView.tsx new file mode 100644 index 0000000..221c469 --- /dev/null +++ b/components/vault/WorkbenchView.tsx @@ -0,0 +1,526 @@ +/** + * Workbench (工作台) —— NetMesh 概览 Dashboard v2 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import * as echarts from "echarts"; +import { + Server, KeyRound, GitBranch, FileCode, Network, AlertTriangle, Activity, + Zap, Loader2, Terminal, ArrowRight, ChevronRight, +} from "lucide-react"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; +import { useConnectionLogsStore } from "../../application/state/connectionLogsStore"; +import { + inspectionDeviceApi, inspectionCatalogApi, inspectionResultApi, + inspectionAlertApi, inspectionReportApi, inspectionDailyReportApi, + type DeviceExtension, type DeviceCatalog, type DailyReport, + type AlertRecord, type ReportSnapshot, +} from "../../infrastructure/inspection/inspectionApi"; + +/* =================== 类型 =================== */ +interface WorkbenchHost { id?: string; label?: string; hostname?: string; tags?: string[]; group?: string; deviceType?: string; } +interface WorkbenchKey { id?: string; label?: string; } +interface WorkbenchProxy { id?: string; label?: string; } +interface WorkbenchSectionProps { + hosts: WorkbenchHost[]; keys: WorkbenchKey[]; proxies: WorkbenchProxy[]; + snippetCount?: number; activePortForwardCount?: number; + onNavigate: (section: string, opts?: { alertId?: string; logId?: string; hostId?: string }) => void; + onConnectHost?: (hostId: string) => void; +} +interface InspectResult { deviceId?: string; hostId?: string; device_id?: string; + metrics?: { cpu?: number; memory?: number; online?: boolean }; + timestamp?: number; collectedAt?: number | string; status?: string; online?: boolean; } + +function getInspectionApi(): any { try { return (window as any).netcatty?.inspection ?? null; } catch { return null; } } + +/* =================== 设备类别定义(单一数据源) =================== */ +const DEVICE_CATEGORY_META: Record = { + server: { label: "服务器", color: "#3b82f6" }, + switch: { label: "交换机", color: "#10b981" }, + router: { label: "路由器", color: "#f59e0b" }, + firewall: { label: "防火墙", color: "#ef4444" }, + storage: { label: "存储设备", color: "#8b5cf6" }, + workstation:{ label: "工作站", color: "#06b6d4" }, + other: { label: "未分类", color: "#64748b" }, +}; +const DEVICE_CATEGORY_KEYS = Object.keys(DEVICE_CATEGORY_META); +function deviceTypeLabel(t?: string): string { + const meta = DEVICE_CATEGORY_META[(t || "").toLowerCase()]; + return meta ? meta.label : "未分类"; +} +function deviceTypeColor(key: string): string { + return DEVICE_CATEGORY_META[key]?.color ?? "#64748b"; +} +const CATALOG_TYPE_TO_DISPLAY: Record = { + linux: "server", windows: "server", huawei_h3c_switch: "switch", cisco_switch: "switch", + huawei_firewall: "firewall", fortinet_firewall: "firewall", +}; +function mapCatalogTypeKeyToCategory(tk: string, name: string, vendor: string): string { + const key = tk.toLowerCase(); + if (CATALOG_TYPE_TO_DISPLAY[key]) return CATALOG_TYPE_TO_DISPLAY[key]; + if (DEVICE_CATEGORY_KEYS.includes(key)) return key; + const blob = `${tk} ${name} ${vendor}`.toLowerCase(); + if (/h3c|huawei|vrp|comware|cisco|switch|交换机/.test(blob)) return "switch"; + if (/router|路由/.test(blob)) return "router"; + if (/firewall|防火墙|\bfw\b/.test(blob)) return "firewall"; + if (/storage|存储|nas|san/.test(blob)) return "storage"; + if (/workstation|\bpc\b|桌面/.test(blob)) return "workstation"; + if (/linux|windows|server|服务器/.test(blob)) return "server"; + return "other"; +} +function classifyHostType(host: WorkbenchHost, ext?: DeviceExtension, catalogs?: DeviceCatalog[]): string { + if (ext?.catalog_id && catalogs && catalogs.length > 0) { + const cat = catalogs.find((c) => c.id === ext!.catalog_id); + if (cat) return mapCatalogTypeKeyToCategory(cat.type_key || "", cat.name || "", cat.vendor || ""); + } + const directType = (host as any).type; + if (directType && DEVICE_CATEGORY_KEYS.includes(directType)) return directType; + const distro = ((host as any).distro || (host as any).catalog || (host as any).catalog_id || "").toLowerCase(); + if (distro) return mapCatalogTypeKeyToCategory(distro, "", ""); + const label = (host.label || host.hostname || "").toLowerCase(); + if (/h3c|huawei|vrp|comware|cisco|switch|交换机/.test(label)) return "switch"; + if (/router|路由/.test(label)) return "router"; + if (/firewall|防火墙/.test(label)) return "firewall"; + if (/storage|存储|nas|san/.test(label)) return "storage"; + if (/workstation|\bpc\b|桌面/.test(label)) return "workstation"; + if (/linux|windows|server|服务器/.test(label)) return "server"; + return "other"; +} + +function formatTimeAgo(input: number | string | undefined): string { + if (!input) return "-"; + const t = typeof input === "number" ? input : new Date(input).getTime(); + const diff = Date.now() - t; + if (diff < 60_000) return "刚刚"; + if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`; + if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)} 小时前`; + return `${Math.floor(diff / 86_400_000)} 天前`; +} + +/* =================== KPI 卡 =================== */ +function KpiCard({ icon: Icon, label, value, sub, accent = "bg-primary/10 text-primary" }: + { icon: React.ElementType; label: string; value: React.ReactNode; sub?: string; accent?: string; }) { + return ( +
+
+
+ +
+
+
+
{value}
+ {sub && {sub}} +
+
{label}
+
+
+
+ ); +} +function SectionTitle({ title, action }: { title: string; action?: React.ReactNode }) { + return ( +
+
+ +

{title}

+
+ {action} +
+ ); +} + +/* =================== 通用 ECharts hook =================== */ +function useChart(optionFn: () => echarts.EChartsOption | null, deps: React.DependencyList) { + const ref = useRef(null); + const chartRef = useRef(null); + useEffect(() => { + if (!ref.current) return; + if (!chartRef.current) { + chartRef.current = echarts.init(ref.current); + const onResize = () => chartRef.current?.resize(); + window.addEventListener("resize", onResize); + return () => { window.removeEventListener("resize", onResize); chartRef.current?.dispose(); chartRef.current = null; }; + } + }, []); + useEffect(() => { + if (!chartRef.current) return; + const opt = optionFn(); + if (opt) chartRef.current.setOption(opt, true); else chartRef.current.clear(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + return ref; +} + +/* =================== 近一周正常/离线堆叠柱状图 =================== */ +function WeeklyOnlineOfflineBarChart({ results }: { results: InspectResult[] }) { + const ref = useChart(() => { + const days: string[] = [], online: number[] = [], offline: number[] = []; + const now = new Date(); + for (let i = 6; i >= 0; i--) { + const d = new Date(now); d.setDate(d.getDate() - i); + days.push(`${d.getMonth() + 1}/${d.getDate()}`); + const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const end = start + 86_400_000; + const latestByDevice = new Map(); + for (const r of results) { + const t = typeof (r as any).timestamp === "number" ? (r as any).timestamp : new Date((r as any).collectedAt || 0).getTime(); + if (t < start || t >= end) continue; + const did = (r as any).device_id || r.deviceId || r.hostId; if (!did) continue; + const exist = latestByDevice.get(did); const eTs = exist ? ((exist as any).timestamp || 0) : 0; + if (!exist || t > eTs) latestByDevice.set(did, r); + } + let on = 0, off = 0; + for (const r of latestByDevice.values()) { if (r.online === false || r.metrics?.online === false) off++; else on++; } + online.push(on); offline.push(off); + } + const totalOn = online.reduce((a, b) => a + b, 0), totalOff = offline.reduce((a, b) => a + b, 0); + return { + grid: { left: 32, right: 12, top: 28, bottom: 28 }, + tooltip: { trigger: "axis", axisPointer: { type: "shadow" } }, + legend: { top: 4, right: 12, textStyle: { fontSize: 11, color: "var(--muted-foreground)" }, itemWidth: 10, itemHeight: 6 }, + title: { text: `${totalOn + totalOff} 台次 · ${totalOn} 在线 / ${totalOff} 离线`, + textStyle: { fontSize: 11, color: "var(--muted-foreground)", fontWeight: 400 }, left: 12, top: 4 }, + xAxis: { type: "category", data: days, axisLabel: { fontSize: 10, color: "var(--muted-foreground)" }, + axisLine: { lineStyle: { color: "var(--border)" } }, axisTick: { show: false } }, + yAxis: { type: "value", splitLine: { lineStyle: { color: "var(--border)", type: "dashed" } }, + axisLabel: { fontSize: 10, color: "var(--muted-foreground)" } }, + series: [ + { name: "在线", type: "bar", stack: "status", barWidth: "48%", emphasis: { focus: "series" }, data: online, + itemStyle: { borderRadius: [0, 0, 0, 0], color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, + colorStops: [{ offset: 0, color: "#10b981" }, { offset: 1, color: "#059669" }] } } }, + { name: "离线", type: "bar", stack: "status", emphasis: { focus: "series" }, data: offline, + itemStyle: { borderRadius: [4, 4, 0, 0], color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, + colorStops: [{ offset: 0, color: "#ef4444" }, { offset: 1, color: "#b91c1c" }] } } }, + ], + } as echarts.EChartsOption; + }, [results]); + return
; +} + +/* =================== 设备类型分布圆环图 =================== */ +function DeviceTypeDonutChart({ hosts, extensions, catalogs }: + { hosts: WorkbenchHost[]; extensions?: Record; catalogs?: DeviceCatalog[] }) { + const ref = useChart(() => { + // 从 DEVICE_CATEGORY_META 动态构建 counts,彻底消除硬编码 + const counts: Record = {}; + for (const key of DEVICE_CATEGORY_KEYS) counts[key] = 0; + for (const h of hosts) { + const ext = extensions && h.id ? extensions[h.id] : undefined; + const cat = classifyHostType(h, ext, catalogs); + if (counts[cat] !== undefined) counts[cat]++; + else counts["other"]++; // 兜底:未知类别归入 other + } + const data = DEVICE_CATEGORY_KEYS + .map((k) => ({ name: deviceTypeLabel(k), value: counts[k], color: deviceTypeColor(k) })) + .filter((d) => d.value > 0); + const total = data.reduce((s, d) => s + d.value, 0); + if (total === 0) return null; + return { + tooltip: { trigger: "item", formatter: "{b}: {c} 台 ({d}%)" }, + title: { text: `${total}`, subtext: "台设备", left: "34%", top: "center", + textStyle: { fontSize: 20, fontWeight: 700, color: "var(--foreground)" }, + subtextStyle: { fontSize: 10, color: "var(--muted-foreground)" } }, + series: [{ type: "pie", radius: ["48%", "72%"], center: ["34%", "50%"], avoidLabelOverlap: true, + itemStyle: { borderRadius: 4, borderColor: "var(--card)", borderWidth: 2 }, label: { show: false }, labelLine: { show: false }, + data: data.map((d) => ({ name: d.name, value: d.value, itemStyle: { color: d.color } })) }], + // graphic 直接用 data[i].color,不再靠 label 反查 index + graphic: data.map((d, i) => ({ + type: "text", left: "68%", top: `${18 + i * 16}%`, + style: { + text: `{dot|} {lbl|${d.name}} {val|${d.value}} {pct|${((d.value / total) * 100).toFixed(1)}%}`, + rich: { + dot: { width: 8, height: 8, backgroundColor: d.color, borderRadius: 4, padding: [0, 6, 0, 0] }, + lbl: { color: "var(--foreground)", fontSize: 11 }, + val: { color: "var(--foreground)", fontSize: 12, fontWeight: 600, padding: [0, 5, 0, 0] }, + pct: { color: "var(--muted-foreground)", fontSize: 11 }, + }, + }, + })), + } as echarts.EChartsOption; + }, [hosts, extensions, catalogs]); + return
; +} + +/* =================== 滚动行组件 =================== */ +function AlertScrollRow({ alert, onClick }: { alert: AlertRecord; onClick?: () => void }) { + const levelStyles: Record = { critical: "bg-red-500/15 text-red-500", warning: "bg-amber-500/15 text-amber-500", info: "bg-sky-500/15 text-sky-500" }; + const levelLabel: Record = { critical: "严重", warning: "警告", info: "提示" }; + return ( + + ); +} +function ReportScrollRow({ report, onClick }: { report: ReportSnapshot; onClick?: () => void }) { + const dateLabel = new Date(report.timestamp).toLocaleDateString(); + return ( + + ); +} +function DailyReportScrollRow({ report, onClick }: { report: DailyReport; onClick?: () => void }) { + const modeLabel = report.mode === "finalized" ? "已发布" : "待确认"; + const statusColor = report.mode === "finalized" ? "bg-emerald-500/15 text-emerald-500" : "bg-amber-500/15 text-amber-500"; + return ( + + ); +} +function QuickConnectRow({ host, onConnect }: { host: WorkbenchHost; onConnect?: (host: WorkbenchHost) => void }) { + const label = host.label || host.hostname || "未命名"; + return ( + + ); +} +function RecentSessionRow({ log, hosts, onClick }: { log: any; hosts: WorkbenchHost[]; onClick?: () => void }) { + const host = hosts.find((h) => h.id === log.hostId); + const label = host?.label || log.hostLabel || log.hostname || "未知"; + const isError = (log.exitCode ?? null) !== null && (log.exitCode ?? null) !== undefined; + return ( + + ); +} + +/* =================== 主组件 =================== */ +export function WorkbenchView({ hosts, keys, proxies, snippetCount = 0, activePortForwardCount = 0, onNavigate, onConnectHost }: WorkbenchSectionProps) { + const inspectionApi = getInspectionApi(); + const [results, setResults] = useState([]); + const [alerts, setAlerts] = useState([]); + const [reports, setReports] = useState([]); + const [dailyReports, setDailyReports] = useState([]); + const [extensions, setExtensions] = useState>({}); + const [catalogs, setCatalogs] = useState([]); + const [loading, setLoading] = useState(true); + const [lastUpdate, setLastUpdate] = useState(0); + const { connectionLogs } = useConnectionLogsStore(); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + let historyResults: InspectResult[] = []; let latestResults: InspectResult[] = []; + // 优先用按时间范围查(过去 8 天完整历史),避免 getRecent(N) 被今天新结果挤出昨天数据 + try { + const now = Date.now(); + const eightDaysAgo = now - 9 * 86_400_000; // 多一天保险 + historyResults = ((await inspectionResultApi.getByTimeRange(eightDaysAgo, now)) as any) || []; + } catch {} + if (historyResults.length === 0) { + try { historyResults = ((await inspectionResultApi.getRecent(1000)) as any) || []; } catch {} + } + try { latestResults = (await inspectionApi?.getLatestAll?.()) || []; } catch {} + setResults(historyResults.length > 0 ? historyResults : latestResults); + + // 用 inspectionAlertApi(AlertRecord 类型,snake_case 字段) + let alertList: AlertRecord[] = []; + try { alertList = await inspectionAlertApi.getActive(); } catch {} + if (alertList.length === 0) { + try { alertList = (await inspectionApi?.listAlerts?.()) || (await inspectionApi?.getActiveAlerts?.()) || []; } catch {} + } + setAlerts(alertList); + + try { setReports(await inspectionReportApi.listSnapshots(15)); } catch {} + try { const daily = await inspectionDailyReportApi.list(15); setDailyReports(Array.isArray(daily) ? daily : []); } catch {} + try { + const [exts, cats] = await Promise.all([ + inspectionDeviceApi.getAll().catch(() => ({})), inspectionCatalogApi.list().catch(() => []), + ]); + if (exts && typeof exts === "object") setExtensions(exts as Record); + if (Array.isArray(cats)) setCatalogs(cats); + } catch {} + setLastUpdate(Date.now()); + } catch (e: any) { console.warn("[Workbench] fetch failed:", e?.message || e); } + finally { setLoading(false); } + }, [inspectionApi]); + + useEffect(() => { void fetchData(); const t = setInterval(() => void fetchData(), 60_000); return () => clearInterval(t); }, [fetchData]); + + const stats = useMemo(() => { + const latestByDevice = new Map(); + for (const r of results) { + const id = (r as any).device_id || r.deviceId || r.hostId; if (!id) continue; + const exist = latestByDevice.get(id); + const rTs = (r as any).timestamp || 0; const eTs = exist ? ((exist as any).timestamp || 0) : 0; + if (!exist || rTs > eTs) latestByDevice.set(id, r); + } + let online = 0, offline = 0; + for (const r of latestByDevice.values()) { if (r.online === false || r.metrics?.online === false) offline++; else online++; } + return { online, offline }; + }, [results]); + + const recentLogs = useMemo(() => + [...(connectionLogs || [])].sort((a: any, b: any) => (b.startTime || b.startedAt || 0) - (a.startTime || a.startedAt || 0)).slice(0, 15), + [connectionLogs]); + + const quickConnectHosts = useMemo(() => hosts, [hosts]); + + const handleQuickConnect = useCallback((host: WorkbenchHost) => { + // 优先开终端 → 没有 hostId 时 fallback 到跳 hosts section 选中 + if (onConnectHost && host.id) { onConnectHost(host.id); return; } + if (host.id) onNavigate("hosts", { hostId: host.id }); + else onNavigate("hosts"); + }, [onConnectHost, onNavigate]); + + const alertList = alerts.slice(0, 30), reportList = reports.slice(0, 20), dailyReportList = dailyReports.slice(0, 20); + const navigate = useCallback((section: string, opts?: any) => onNavigate(section, opts), [onNavigate]); + + // === 复用已有页面的预览/详情 === + const handlePreviewSnapshot = useCallback(async (snap: ReportSnapshot) => { + try { await inspectionReportApi.previewSnapshotPdf(snap.id); } catch { navigate("reports"); } + }, [navigate]); + const handlePreviewDaily = useCallback(async (r: DailyReport) => { + try { await inspectionDailyReportApi.previewPdf(r.id); } catch { navigate("reports"); } + }, [navigate]); + + return ( +
+ {/* 顶部标题条 */} +
+
+

工作台

+

+ {loading ? "加载中..." : lastUpdate ? `最后更新 ${formatTimeAgo(lastUpdate)}` : "暂无巡检数据"} +

+
+ +
+ + {/* ============ 8 KPI 卡 ============ */} +
+ + + + + + + + +
+ + {/* ============ 主体:外层 3 列 grid,左 2 列 + 右 1 列各自独立 ============ */} +
+ {/* ===== 左侧 2 列:flex-col 独立容器 ===== */} +
+ {/* 左侧 Row A:快速连接 + 最近会话 */} +
+
+ navigate("hosts")} className="h-7 text-xs">全部 } /> + {quickConnectHosts.length === 0 ?
还没有主机
+ :
{quickConnectHosts.map((h) => )}
} +
+
+ navigate("logs")} className="h-7 text-xs">全部 } /> + {recentLogs.length === 0 ?
暂无记录
+ :
{recentLogs.map((log) => navigate("logs", { logId: log.id || log.sessionId })} />)}
} +
+
+ {/* 左侧 Row B:巡检快照 + 巡检日报 */} +
+
+ navigate("reports")} className="h-7 text-xs">全部 } /> + {reportList.length === 0 ?
暂无快照
+ :
{reportList.map((r) => handlePreviewSnapshot(r)} />)}
} +
+
+ navigate("reports")} className="h-7 text-xs">全部 } /> + {dailyReportList.length === 0 ?
暂无日报
+ :
{dailyReportList.map((r) => handlePreviewDaily(r)} />)}
} +
+
+ {/* 左侧 Row C:快捷操作 */} +
+ +
+ {[ + { icon: KeyRound, label: "密钥管理", sub: "管理 SSH 密钥", section: "keys", accent: "from-amber-500/20 to-amber-500/5 text-amber-600 border-amber-500/30 hover:from-amber-500/30 hover:to-amber-500/10" }, + { icon: Network, label: "端口转发", sub: "管理端口映射", section: "port", accent: "from-blue-500/20 to-blue-500/5 text-blue-600 border-blue-500/30 hover:from-blue-500/30 hover:to-blue-500/10" }, + { icon: FileCode, label: "代码片段", sub: "常用代码块", section: "snippets", accent: "from-violet-500/20 to-violet-500/5 text-violet-600 border-violet-500/30 hover:from-violet-500/30 hover:to-violet-500/10" }, + { icon: Zap, label: "告警规则", sub: "配置告警策略", section: "alertRules", accent: "from-rose-500/20 to-rose-500/5 text-rose-600 border-rose-500/30 hover:from-rose-500/30 hover:to-rose-500/10" }, + { icon: Server, label: "监控大屏", sub: "拓扑可视化", section: "monitoring", accent: "from-emerald-500/20 to-emerald-500/5 text-emerald-600 border-emerald-500/30 hover:from-emerald-500/30 hover:to-emerald-500/10" }, + ].map(({ icon: Icon, label, sub, section, accent }) => ( + + ))} +
+
+
+ + {/* ===== 右侧 1 列:flex-col 独立容器 ===== */} +
+ {/* 右侧 A:设备类型分布 — 自然矮 */} +
+ + {hosts.length === 0 ?
暂无主机数据
+ :
} +
+ {/* 右侧 B:近一周巡检状态 — 自然矮 */} +
+ navigate("inspection")} className="h-7 text-xs">巡检中心 } /> + {results.length === 0 ?
暂无巡检数据
+ :
} +
+ {/* 右侧 C:活跃告警 — flex-1 填满剩余空间 */} +
+ navigate("alerts")} className="h-7 text-xs">全部 } /> + {alertList.length === 0 ?
全部正常
+ :
{alertList.map((a) => navigate("alerts", { alertId: a.id })} />)}
} +
+
+
+
+ ); +} + +export default WorkbenchView; diff --git a/electron/bridges/inspection/alertRuleStore.cjs b/electron/bridges/inspection/alertRuleStore.cjs index 864f497..1c447e5 100644 --- a/electron/bridges/inspection/alertRuleStore.cjs +++ b/electron/bridges/inspection/alertRuleStore.cjs @@ -527,6 +527,13 @@ function getRulesForDevice(readFn, deviceExt) { }); } +// ---------- 批量保存(远程 HTTP 用) ---------- +function saveRules(readFn, writeFn, rules) { + _rules = Array.isArray(rules) ? rules : []; + _save(writeFn); + return _rules; +} + // ---------- 重置(测试用) ---------- function _resetForTest() { _rules = null; @@ -541,5 +548,6 @@ module.exports = { toggleRule, evaluateRule, getRulesForDevice, + saveRules, _resetForTest, }; diff --git a/electron/bridges/inspection/index.cjs b/electron/bridges/inspection/index.cjs index b0d7d3a..774f5e6 100644 --- a/electron/bridges/inspection/index.cjs +++ b/electron/bridges/inspection/index.cjs @@ -90,6 +90,17 @@ function broadcastRuleChanged() { } catch (_) {} } +// 相机配置广播(Settings 改了 → 所有 window 里的监控 view 收到) +function broadcastCameraConfigUpdated(cfg) { + try { + const { webContents } = require("electron"); + for (const wc of webContents.getAllWebContents()) { + wc.send("inspection:camera-config-updated", cfg); + } + console.info("[inspection] broadcastCameraConfigUpdated sent"); + } catch (_) {} +} + // AI 汇总状态广播(主进程主动推送 → renderer loadSnapshots 刷新) function _broadcastReportAiStatus(snapshotId, status, error) { try { @@ -504,6 +515,10 @@ function registerInspectionHandlers(ipcMain, options) { return inspectionResultStore.getRecent(readFn, limit); }); + ipcMain.handle("inspection:getResultsByTimeRange", (_e, startTime, endTime) => { + return inspectionResultStore.getByTimeRange(readFn, startTime, endTime); + }); + // ---------- 单次巡检执行(前端传入完整主机配置) ---------- ipcMain.handle("inspection:runInspection", async (_e, payload) => { const { hostId, hostConfig, catalogId } = payload || {}; @@ -1707,6 +1722,168 @@ function registerInspectionHandlers(ipcMain, options) { } }); + // ---------- 监控大屏地图底图持久化 ---------- + const _mapsDir = options.userDataDir && options.path + ? options.path.join(options.userDataDir, "monitoring", "maps") + : null; + + function _ensureMapsDir() { + if (!_mapsDir || !options.fs) throw new Error("maps dir not available"); + if (!options.fs.existsSync(_mapsDir)) { + options.fs.mkdirSync(_mapsDir, { recursive: true }); + } + } + + const _ALLOWED_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp"]); + const _MAX_BG_BYTES = 20 * 1024 * 1024; // 20MB + + function _sanitizeBgFilename(raw) { + if (typeof raw !== "string" || !raw.trim()) return ""; + // 只保留安全字符 + 扩展名 + const base = raw.replace(/[\\/]/g, "_").replace(/[^\w.\-\u4e00-\u9fff]/g, ""); + return base.length > 0 ? base : "map"; + } + + ipcMain.handle("inspection:uploadMapBg", (_e, { filename, base64Data }) => { + try { + if (!_mapsDir) return { ok: false, error: "maps dir not available" }; + if (typeof filename !== "string" || !filename) return { ok: false, error: "filename required" }; + if (typeof base64Data !== "string" || !base64Data) return { ok: false, error: "base64Data required" }; + + // 类型校验 + const ext = options.path.extname(filename).toLowerCase(); + if (!_ALLOWED_EXTS.has(ext)) { + return { ok: false, error: "unsupported type, allowed: png / jpg / jpeg / webp" }; + } + + // 大小校验 + const buf = Buffer.from(base64Data, "base64"); + if (buf.length > _MAX_BG_BYTES) { + return { ok: false, error: "file too large, max 20MB" }; + } + + _ensureMapsDir(); + const safeName = _sanitizeBgFilename(filename); + const ts = Date.now(); + const storedName = `${ts}_${safeName}`; + const target = options.path.join(_mapsDir, storedName); + options.fs.writeFileSync(target, buf); + + console.info("[inspection] uploadMapBg ok:", storedName, "size:", buf.length); + return { ok: true, filename: storedName, size: buf.length }; + } catch (e) { + console.error("[inspection] uploadMapBg error:", e?.message); + return { ok: false, error: e?.message }; + } + }); + + ipcMain.handle("inspection:listMapBgs", () => { + try { + if (!_mapsDir || !options.fs) return []; + if (!options.fs.existsSync(_mapsDir)) return []; + const all = options.fs.readdirSync(_mapsDir); + console.info("[inspection] listMapBgs dir:", _mapsDir, "raw entries:", all); + const entries = all.filter((f) => { + const ext = options.path.extname(f).toLowerCase(); + const ok = _ALLOWED_EXTS.has(ext); + console.info("[inspection] listMapBgs filter:", f, "ext:", ext, "ok:", ok); + return ok; + }); + return entries + .map((name) => { + const stat = options.fs.statSync(options.path.join(_mapsDir, name)); + return { filename: name, size: stat.size, mtimeMs: stat.mtimeMs }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + } catch (e) { + console.error("[inspection] listMapBgs error:", e?.message); + return []; + } + }); + + ipcMain.handle("inspection:selectMapBg", (_e, filename) => { + try { + if (typeof filename !== "string" || !filename) return { ok: false, error: "filename required" }; + // 校验文件实际存在 + if (_mapsDir && options.fs) { + const fullPath = options.path.join(_mapsDir, filename); + if (!options.fs.existsSync(fullPath)) { + return { ok: false, error: "file not found" }; + } + } + writeStorage("_map_bg_selected", filename); + return { ok: true, filename }; + } catch (e) { + return { ok: false, error: e?.message }; + } + }); + + ipcMain.handle("inspection:getMapBgData", () => { + try { + const selected = readStorage("_map_bg_selected"); + if (!selected || typeof selected !== "string") { + return { ok: false, reason: "none" }; + } + if (!_mapsDir || !options.fs) return { ok: false, reason: "no-dir" }; + const fullPath = options.path.join(_mapsDir, selected); + if (!options.fs.existsSync(fullPath)) { + // 选中的文件已被删除 → 清理 selected + writeStorage("_map_bg_selected", null); + return { ok: false, reason: "missing" }; + } + const buf = options.fs.readFileSync(fullPath); + const ext = options.path.extname(selected).toLowerCase(); + const mimeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" }; + const mime = mimeMap[ext] || "image/png"; + const dataUrl = `data:${mime};base64,${buf.toString("base64")}`; + return { ok: true, filename: selected, dataUrl, size: buf.length }; + } catch (e) { + console.error("[inspection] getMapBgData error:", e?.message); + return { ok: false, error: e?.message }; + } + }); + + ipcMain.handle("inspection:deleteMapBg", (_e, filename) => { + try { + if (!_mapsDir || !options.fs) return { ok: false, error: "maps dir not available" }; + const fullPath = options.path.join(_mapsDir, filename); + if (options.fs.existsSync(fullPath)) { + options.fs.unlinkSync(fullPath); + } + // 如果删的是当前选中的 → 清空 selected + const selected = readStorage("_map_bg_selected"); + if (selected === filename) { + writeStorage("_map_bg_selected", null); + } + return { ok: true }; + } catch (e) { + return { ok: false, error: e?.message }; + } + }); + + ipcMain.handle("inspection:resetMapBg", () => { + try { + writeStorage("_map_bg_selected", null); + return { ok: true }; + } catch (e) { + return { ok: false, error: e?.message }; + } + }); + + // 相机配置:Settings window 发过来,主进程存 localStorage + 广播给所有 window + ipcMain.handle("inspection:setCameraConfig", (_e, cfg) => { + try { + if (!cfg || typeof cfg !== "object") return { ok: false, error: "cfg required" }; + // 主进程侧也存一份 localStorage(和渲染进程隔离的 session) + // 但渲染进程各 window 共享同一个 session,所以写一遍即可 + broadcastCameraConfigUpdated(cfg); + return { ok: true }; + } catch (e) { + console.error("[inspection] setCameraConfig error:", e?.message); + return { ok: false, error: e?.message }; + } + }); + ipcMain.handle("inspection:getDashboardTitle", () => { try { return readStorage("_dashboard_title") || ""; diff --git a/electron/bridges/inspection/inspectionResultStore.cjs b/electron/bridges/inspection/inspectionResultStore.cjs index 82f02c8..d18f13b 100644 --- a/electron/bridges/inspection/inspectionResultStore.cjs +++ b/electron/bridges/inspection/inspectionResultStore.cjs @@ -10,7 +10,7 @@ const { STORAGE_KEY_INSPECTION_RESULTS } = require("./storageKeys.cjs"); const { INSPECTION_STATUS } = require("./types.cjs"); -const MAX_RESULTS = 1000; +const MAX_RESULTS = 10000; let _results = null; diff --git a/electron/main/registerBridges.cjs b/electron/main/registerBridges.cjs index d54a8b1..e149ff4 100644 --- a/electron/main/registerBridges.cjs +++ b/electron/main/registerBridges.cjs @@ -509,6 +509,10 @@ function createBridgeRegistrar(context) { writeStorage: (key, val) => inspectionStorage.writeStorage(key, val), // 传入 sessions 用于复用终端 SSH 连接 sessions, + // 地图底图持久化:需要在主进程 userData 下写二进制图片 + userDataDir, + path: require("node:path"), + fs: require("node:fs"), }); // 给 MCP capability dispatch 里的 inspection handlers 也注入 sessions(RCA agent 用) @@ -1194,6 +1198,63 @@ function createBridgeRegistrar(context) { return { success: false }; } }); + + // ─── 设置同步(主窗口 ↔ 远程浏览器 共享权威源) ─── + // 主窗口 Electron 写设置后调这个 handler 写到 inspectionStorage, + // 这样远程浏览器 GET /api/settings 就能看到最新值 + // 写完后广播给所有 renderer(主窗口实时更新) + function broadcastSettingsChanged() { + try { + for (const wc of require("electron").webContents.getAllWebContents()) { + wc.send("netcatty:settings:changed"); + } + } catch {} + } + ipcMain.handle("netcatty:settings:sync", (_event, { key, value, delete: shouldDelete } = {}) => { + try { + let all = {}; + const raw = inspectionStorage.readStorage("__settings__all__"); + if (raw) { try { all = typeof raw === "string" ? JSON.parse(raw) : raw; } catch {} } + if (shouldDelete) { + delete all[key]; + } else { + all[key] = value; + } + inspectionStorage.writeStorage("__settings__all__", JSON.stringify(all)); + broadcastSettingsChanged(); + return { ok: true }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + }); + ipcMain.handle("netcatty:settings:getAll", () => { + try { + const raw = inspectionStorage.readStorage("__settings__all__"); + let all = {}; + if (raw) { try { all = typeof raw === "string" ? JSON.parse(raw) : raw; } catch {} } + return { ok: true, settings: all }; + } catch (e) { + return { ok: false, error: e?.message || String(e), settings: {} }; + } + }); + ipcMain.handle("netcatty:settings:syncBatch", (_event, { entries } = {}) => { + try { + let all = {}; + const raw = inspectionStorage.readStorage("__settings__all__"); + if (raw) { try { all = typeof raw === "string" ? JSON.parse(raw) : raw; } catch {} } + if (entries && typeof entries === "object") { + for (const [k, v] of Object.entries(entries)) { + if (v === null || v === undefined) delete all[k]; + else all[k] = v; + } + } + inspectionStorage.writeStorage("__settings__all__", JSON.stringify(all)); + broadcastSettingsChanged(); + return { ok: true, count: entries ? Object.keys(entries).length : 0 }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + }); console.log('[Main] All bridges registered successfully'); @@ -1205,7 +1266,8 @@ function createBridgeRegistrar(context) { ipcMain.handle("remoteHttp:getStatus", () => remoteHttp.getStatus()); ipcMain.handle("remoteHttp:configure", (_e, enabled, port) => remoteHttp.configure(enabled, port)); // 自动启动(打包版才有意义,开发版也启方便调试) - remoteHttp.start({ storage: inspectionStorage }).catch((e) => { + // storage: inspectionStorage 共享权威源,onSettingsChanged 远程写完后广播给主窗口 + remoteHttp.start({ storage: inspectionStorage, onSettingsChanged: broadcastSettingsChanged }).catch((e) => { console.warn("[RemoteHTTP] auto-start failed:", e.message); }); } catch (e) { diff --git a/electron/preload.cjs b/electron/preload.cjs index 3db68f5..7b89633 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -947,6 +947,66 @@ const allowedOrigins = getAllowedRendererOrigins(); if (isTrustedRendererLocation(allowedOrigins)) { contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api, ...figSpecApi, + // 设置同步:renderer localStorage 变化通知主进程权威源 + settingsSync: (key, value) => ipcRenderer.invoke("netcatty:settings:sync", { + key, + value, + delete: value === null, + }), + settingsSyncBatch: (entries) => ipcRenderer.invoke("netcatty:settings:syncBatch", { entries }), + settingsGetAll: () => ipcRenderer.invoke("netcatty:settings:getAll"), + // 远程浏览器改了设置 → 主窗口实时拉取更新 localStorage + onSettingsChanged: (callback) => { + const handler = () => { + ipcRenderer.invoke("netcatty:settings:getAll").then((r) => { + if (r?.ok && r.settings && typeof r.settings === "object") { + // 逐键对比更新(不暴力清空,避免触发过度重渲染) + try { + const changedKeys = []; + const changedEntries = {}; + const existingKeys = new Set(Object.keys(localStorage)); + for (const [k, v] of Object.entries(r.settings)) { + if (v === null || v === undefined) { + if (localStorage.getItem(k) !== null) { + localStorage.removeItem(k); + changedKeys.push(k); + changedEntries[k] = null; + } + } else { + const strVal = String(v); + if (localStorage.getItem(k) !== strVal) { + localStorage.setItem(k, strVal); + changedKeys.push(k); + changedEntries[k] = strVal; + } + } + existingKeys.delete(k); + } + // 权威源里没有但本地有的键——保留(可能是 Electron 专有设置) + // 只有实际有变化时才触发事件 + if (changedKeys.length > 0) { + // 触发同窗口 CustomEvent 让 React hooks 感知(携带完整 settings 和变化的 key 列表) + try { + window.dispatchEvent(new CustomEvent("netcatty-settings-batch", { + detail: { changedKeys, changedEntries, settings: r.settings }, + })); + } catch {} + try { window.dispatchEvent(new Event("monitoring-camera-changed")); } catch {} + // 向后兼容:触发原生 storage 事件(不带 key 信息,同窗口内依赖它的 hook 至少能收到通知) + try { window.dispatchEvent(new Event("storage")); } catch {} + // 回调同时传递完整 settings 字典和逐 key 变化信息 + try { callback({ settings: r.settings, changedKeys, changedEntries }); } catch {} + } else { + // 即使没有变化也通知(权威源同步确认) + try { callback({ settings: r.settings, changedKeys: [], changedEntries: {} }); } catch {} + } + } catch {} + } + }).catch(() => {}); + }; + ipcRenderer.on("netcatty:settings:changed", handler); + return () => ipcRenderer.removeListener("netcatty:settings:changed", handler); + }, // portable 版标志:renderer 据此跳过 LS hosts_v1 加密读取, // 直接走 inspection-data.json 明文(DPAPI 绑定 userData 路径解不开 dev 加密 blob)。 isPortable: process.env.NETCATTY_PORTABLE === "1", diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index 1021957..8b7c79a 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -2094,6 +2094,7 @@ function createPreloadApi(ctx) { getLatestAll: () => ipcRenderer.invoke("inspection:getLatestAll"), getLatestByDevice: (hostId, limit) => ipcRenderer.invoke("inspection:getLatestByDevice", hostId, limit), getRecentResults: (limit) => ipcRenderer.invoke("inspection:getRecentResults", limit), + getResultsByTimeRange: (startTime, endTime) => ipcRenderer.invoke("inspection:getResultsByTimeRange", startTime, endTime), listAlerts: (options) => ipcRenderer.invoke("inspection:listAlerts", options), getActiveAlerts: () => ipcRenderer.invoke("inspection:getActiveAlerts"), getAlertStats: () => ipcRenderer.invoke("inspection:getAlertStats"), @@ -2140,6 +2141,21 @@ function createPreloadApi(ctx) { getVaultData: (key) => ipcRenderer.invoke("inspection:getVault:" + key), saveTopologyLinks: (links) => ipcRenderer.invoke("inspection:saveTopologyLinks", links), getTopologyLinks: () => ipcRenderer.invoke("inspection:getTopologyLinks"), + // 监控大屏地图底图(用户上传 userData/monitoring/maps,Three.js 渲染消费) + uploadMapBg: (filename, base64Data) => ipcRenderer.invoke("inspection:uploadMapBg", { filename, base64Data }), + listMapBgs: () => ipcRenderer.invoke("inspection:listMapBgs"), + selectMapBg: (filename) => ipcRenderer.invoke("inspection:selectMapBg", filename), + getMapBgData: () => ipcRenderer.invoke("inspection:getMapBgData"), + deleteMapBg: (filename) => ipcRenderer.invoke("inspection:deleteMapBg", filename), + resetMapBg: () => ipcRenderer.invoke("inspection:resetMapBg"), + // 相机配置:Settings window 调这个 → 主进程广播给所有 window + setCameraConfig: (cfg) => ipcRenderer.invoke("inspection:setCameraConfig", cfg), + // 监听主进程推过来的相机配置变化 + onCameraConfigUpdated: (callback) => { + const handler = (_event, cfg) => callback(cfg); + ipcRenderer.on("inspection:camera-config-updated", handler); + return () => ipcRenderer.removeListener("inspection:camera-config-updated", handler); + }, // 巡检报告调度配置 getReportSchedule: () => ipcRenderer.invoke("inspection:getReportSchedule"), saveReportSchedule: (config) => ipcRenderer.invoke("inspection:saveReportSchedule", config), diff --git a/electron/remoteHttpServer.cjs b/electron/remoteHttpServer.cjs index 2182f03..45178ae 100644 --- a/electron/remoteHttpServer.cjs +++ b/electron/remoteHttpServer.cjs @@ -15,12 +15,63 @@ const http = require("http"); const fs = require("fs"); const path = require("path"); +const os = require("os"); const crypto = require("crypto"); +// ─── 获取本机局域网 IPv4(优先以太网,过滤虚拟网卡) ──── +function getLanIp() { + try { + const nets = os.networkInterfaces(); + const candidates = []; + // 虚拟/内部/隧道网卡关键词(命中即跳过) + const VIRTUAL = [ + "loopback", "docker", "veth", "vmware", "virtualbox", "hyper-v", "vEthernet", + "vbox", "bridge", "tailscale", "zerotier", "wireguard", "tun", "tap", + "bluetooth", "bth", "wsl", "localhost", "回环", "虚拟", "蓝牙", + ]; + // WiFi 关键词(降权) + const WIFI = ["wlan", "wi-fi", "wifi", "wireless", "无线"]; + // 以太网关键词(优先) + const ETHERNET = ["以太网", "ethernet", "eth", "enp", "ens", "eno", "bond", "team"]; + + for (const ifaceName of Object.keys(nets)) { + const lower = ifaceName.toLowerCase(); + // 过滤虚拟网卡 + if (VIRTUAL.some((k) => lower.includes(k.toLowerCase()))) continue; + + for (const net of nets[ifaceName] || []) { + if (net.family !== "IPv4" || net.internal || !net.address) continue; + const addr = net.address; + // 只保留内网段 IP + const isPrivate = + /^192\.168\./.test(addr) || + /^10\./.test(addr) || + /^172\.(1[6-9]|2\d|3[01])\./.test(addr); + if (!isPrivate) continue; + + // 基础分:以太网 +100,WiFi +50,其他 +10 + let score = 10; + if (ETHERNET.some((k) => lower.includes(k.toLowerCase()))) score = 100; + else if (WIFI.some((k) => lower.includes(k.toLowerCase()))) score = 50; + + candidates.push({ addr, score, iface: ifaceName }); + } + } + + if (candidates.length === 0) return "127.0.0.1"; + candidates.sort((a, b) => b.score - a.score); + console.log("[RemoteHTTP] getLanIp 候选:", candidates.map((c) => `${c.addr}(${c.iface},score=${c.score})`).join(", ")); + return candidates[0].addr; + } catch { + return "127.0.0.1"; + } +} + let _server = null; let _port = 8848; let _token = null; let _storage = null; // { readStorage, writeStorage } +let _onSettingsChanged = null; // 远程写完设置后通知主窗口 let _distDir = null; // 运行时解析的 dist 绝对路径 const STORAGE_KEY = "netcatty_remote_access"; @@ -147,7 +198,8 @@ function loadModules() { const alertRuleStore = require("./bridges/inspection/alertRuleStore.cjs"); const reportStore = require("./bridges/inspection/reportStore.cjs"); const dailyReportStore = require("./bridges/inspection/dailyReportStore.cjs"); - return { inspectionStorage, deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore, reportStore, dailyReportStore }; + const reportScheduleStore = require("./bridges/inspection/reportScheduleStore.cjs"); + return { inspectionStorage, deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore, reportStore, dailyReportStore, reportScheduleStore }; } // 每次 API 请求前重置所有 store 的模块级缓存,强制从磁盘重新加载 @@ -255,6 +307,62 @@ function readStorage(key) { // ─── 路由 ──── async function handleApi(pathname, method, body, query) { + // /api/settings 路由不依赖 modules,先快速处理 + if (pathname === "/api/settings") { + console.log("[RemoteHTTP]", method, "/api/settings", "body:", typeof body === "string" ? body?.substring(0, 200) : JSON.stringify(body)); + try { + if (method === "GET") { + const all = _storage?.readStorage?.("__settings__all__") || {}; + let parsed = {}; + try { parsed = typeof all === "string" ? JSON.parse(all) : (all || {}); } catch {} + return { ok: true, settings: parsed }; + } + if (method === "POST") { + let parsedBody; + try { parsedBody = JSON.parse(body || "{}"); } catch (e) { + console.warn("[RemoteHTTP] POST /api/settings bad JSON:", body); + return { ok: false, error: "invalid JSON: " + e.message }; + } + const entries = parsedBody.entries || parsedBody; + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + console.warn("[RemoteHTTP] POST /api/settings bad entries:", typeof entries); + return { ok: false, error: "body must be object of entries" }; + } + let current = {}; + try { + const raw = _storage?.readStorage?.("__settings__all__"); + current = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : {}; + } catch {} + const merged = { ...current, ...entries }; + try { _storage?.writeStorage?.("__settings__all__", JSON.stringify(merged)); } catch (e) { + console.warn("[RemoteHTTP] POST /api/settings writeStorage error:", e.message); + } + try { _onSettingsChanged?.(); } catch {} + console.log("[RemoteHTTP] POST /api/settings OK, keys:", Object.keys(entries).length); + return { ok: true, merged }; + } + if (method === "DELETE") { + let parsedBody; + try { parsedBody = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON" }; } + const keys = Array.isArray(parsedBody.keys) ? parsedBody.keys : []; + if (!keys.length) return { ok: false, error: "keys array required" }; + let current = {}; + try { + const raw = _storage?.readStorage?.("__settings__all__"); + current = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : {}; + } catch {} + for (const k of keys) delete current[k]; + try { _storage?.writeStorage?.("__settings__all__", JSON.stringify(current)); } catch {} + try { _onSettingsChanged?.(); } catch {} + return { ok: true, remaining: current }; + } + return { ok: false, error: "method not allowed" }; + } catch (e) { + console.error("[RemoteHTTP] /api/settings error:", e); + return { ok: false, error: String(e?.message || e) }; + } + } + const modules = loadModules(); // 每次请求前重置所有 store 缓存,确保读到磁盘最新数据 resetAllCaches(modules); @@ -349,18 +457,56 @@ async function handleApi(pathname, method, body, query) { return { ok: true, rules }; } + // POST /api/alert/:id/acknowledge —— 确认告警 + const alertActionMatch = pathname.match(/^\/api\/alert\/([\w-]+)\/(acknowledge|resolve|close|note)$/); + if (method === "POST" && alertActionMatch) { + const alertId = alertActionMatch[1]; + const action = alertActionMatch[2]; + let payload; + try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; } + const writeFn = (k, v) => _storage?.writeStorage?.(k, v); + try { + let result; + if (action === "acknowledge") { + result = alertRecordStore.acknowledgeAlert(readStorage, writeFn, alertId, payload.operator || "remote"); + } else if (action === "resolve") { + result = alertRecordStore.resolveAlert(readStorage, writeFn, alertId, payload.reason || "manual", payload.operator || "remote"); + } else if (action === "close") { + result = alertRecordStore.closeAlert(readStorage, writeFn, alertId, payload.note || ""); + } else if (action === "note") { + result = alertRecordStore.updateNote(readStorage, writeFn, alertId, payload.note || ""); + } + return { ok: true, alert: result }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + } + // GET /api/inspection/results —— 所有设备最新巡检结果(完整结构,monitoring 页面用) if (method === "GET" && pathname === "/api/inspection/results") { const all = inspectionResultStore.getLatestAll(readStorage); return { ok: true, results: Object.values(all) }; } - // GET /api/inspection/results/recent —— 最近巡检结果 + // GET /api/inspection/results/recent —— 最近巡检结果(支持 query.limit,默认 500 覆盖 7 天历史) if (method === "GET" && pathname === "/api/inspection/results/recent") { - const all = inspectionResultStore.getRecent(readStorage, 50); + const limit = Math.min(parseInt(query.limit, 10) || 500, 5000); + const all = inspectionResultStore.getRecent(readStorage, limit); return { ok: true, results: all }; } + // GET /api/inspection/results/range —— 按时间范围查询巡检历史(工作台柱状图用) + // 查询参数: start=timestamp&end=timestamp + if (method === "GET" && pathname === "/api/inspection/results/range") { + const start = parseInt(query.start, 10); + const end = parseInt(query.end, 10); + if (!start || !end || start >= end) { + return { ok: false, error: "Invalid start/end" }; + } + const results = inspectionResultStore.getByTimeRange(readStorage, start, end); + return { ok: true, results }; + } + // GET /api/devices/enabled —— 已启用巡检的设备列表 if (method === "GET" && pathname === "/api/devices/enabled") { const enabled = deviceStore.getEnabledDevices(readStorage); @@ -613,9 +759,10 @@ async function handleApi(pathname, method, body, query) { } // GET /api/vault/:key —— Vault 子页面通用只读路由 - // 支持 keys/proxies/port-forwards/snippets/notes/known-hosts/connection-logs/groups/identities - // 敏感数据(keys/identities)自动脱敏 + // 支持 hosts/keys/proxies/port-forwards/snippets/notes/known-hosts/connection-logs/groups/identities + // 敏感数据(keys/identities/hosts)自动脱敏 const _VAULT_ROUTE_MAP = { + hosts: "_vault_hosts", keys: "_vault_keys", proxies: "_vault_proxies", "port-forwards": "_vault_port_forwards", @@ -652,8 +799,8 @@ async function handleApi(pathname, method, body, query) { if (typeof raw === "string") { try { data = JSON.parse(raw); } catch { data = raw; } } - // keys 和 identities 需要脱敏 - if (key === "keys" || key === "identities") { + // keys、identities 和 hosts 需要脱敏(hosts 里可能带 password) + if (key === "keys" || key === "identities" || key === "hosts") { data = _stripSensitive(data); } return { ok: true, data: data ?? (Array.isArray(data) ? [] : {}) }; @@ -662,6 +809,117 @@ async function handleApi(pathname, method, body, query) { } } + // POST /api/alert-rules —— 批量保存告警规则(远程浏览器编辑用) + if (method === "POST" && pathname === "/api/alert-rules") { + let rules; + try { rules = JSON.parse(body || "[]"); } catch { return { ok: false, error: "invalid JSON body" }; } + if (!Array.isArray(rules)) return { ok: false, error: "rules must be an array" }; + try { + alertRuleStore.saveRules(readStorage, (k, v) => _storage?.writeStorage?.(k, v), rules); + return { ok: true, count: rules.length }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + } + + // POST /api/alert-rule —— 单条 create/update/delete/toggle + // body: { action: 'create'|'update'|'delete'|'toggle', ...fields } + if (method === "POST" && pathname === "/api/alert-rule") { + let payload; + try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; } + const write = (k, v) => _storage?.writeStorage?.(k, v); + try { + if (payload.action === "create") { + const rule = alertRuleStore.createRule(readStorage, write, payload.data || payload); + return { ok: true, rule }; + } else if (payload.action === "update") { + const rule = alertRuleStore.updateRule(readStorage, write, payload.id, payload.updates || payload.data); + return { ok: true, rule }; + } else if (payload.action === "delete") { + const ok = alertRuleStore.deleteRule(readStorage, write, payload.id); + return { ok: !!ok }; + } else if (payload.action === "toggle") { + const rule = alertRuleStore.toggleRule(readStorage, write, payload.id, !!payload.enabled); + return { ok: true, rule }; + } + return { ok: false, error: "unknown action" }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + } + + // POST /api/report/schedule —— 保存报告调度配置 + if (method === "POST" && pathname === "/api/report/schedule") { + let config; + try { config = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; } + try { + const { reportScheduleStore } = loadModules(); + const merged = reportScheduleStore.saveSchedule((k, v) => _storage?.writeStorage?.(k, v), config); + return { ok: true, schedule: merged }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + } + + // POST /api/report/scheduler-toggle —— 开启/关闭自动调度 + if (method === "POST" && pathname === "/api/report/scheduler-toggle") { + let payload; + try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; } + const enabled = payload.enabled !== false; + try { + const { reportScheduleStore, reportScheduler } = loadModules(); + const current = reportScheduleStore.getSchedule(readStorage); + const merged = reportScheduleStore.saveSchedule((k, v) => _storage?.writeStorage?.(k, v), { ...current, enabled }); + // 尝试触发调度器启停(远程模式下调度器可能不存在,try/catch 兜底) + try { + if (enabled && reportScheduler?.start) reportScheduler.start(); + else if (!enabled && reportScheduler?.stop) reportScheduler.stop(); + } catch (_) {} + return { ok: true, running: enabled, schedule: merged }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + } + + // GET /api/report/scheduler-status —— 调度器状态 + if (method === "GET" && pathname === "/api/report/scheduler-status") { + try { + const { reportScheduleStore } = loadModules(); + const schedule = reportScheduleStore.getSchedule(readStorage); + return { ok: true, running: !!schedule.enabled, schedule }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + } + + // POST /api/inspection/sys-config —— 更新系统配置 + if (method === "POST" && pathname === "/api/inspection/sys-config") { + let updates; + try { updates = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; } + try { + const { deviceStore } = loadModules(); + deviceStore.updateSysConfig(readStorage, (k, v) => _storage?.writeStorage?.(k, v), updates); + return { ok: true }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + } + + // POST /api/devices/:hostId/enabled —— 单台设备巡检开关 + const enableMatch = pathname.match(/^\/api\/devices\/([\w-]+)\/enabled$/); + if (method === "POST" && enableMatch) { + const hostId = enableMatch[1]; + let enabled = true; + try { enabled = JSON.parse(body || "{}").enabled !== false; } catch {} + try { + const { deviceStore } = loadModules(); + deviceStore.upsertExtension(readStorage, (k, v) => _storage?.writeStorage?.(k, v), hostId, { inspection_enabled: enabled }); + return { ok: true, hostId, enabled }; + } catch (e) { + return { ok: false, error: e?.message || String(e) }; + } + } + // GET /api/report-preview —— 远程浏览器动态生成 PDF 并返回流 // query: mode=snapshot|daily&id= if (method === "GET" && pathname === "/api/report-preview") { @@ -694,11 +952,17 @@ async function handleApi(pathname, method, body, query) { filePath = await reportExport.exportSnapshotPdf(app, snap, snap.ai_summary || undefined, null, systemName); } else if (mode === "daily") { dailyReportStore._resetForTest?.(); - const report = dailyReportStore.getReport(readStorage, id); + const allReports = dailyReportStore.listDailyReports(readStorage, 500); + const report = (allReports || []).find((r) => r?.id === id); if (!report) return { ok: false, error: "日报告不存在" }; reportStore._resetForTest?.(); - const allSnapshots = reportStore.listSnapshots(readStorage); - filePath = await reportExport.exportDailyReportPdf(app, report, null, allSnapshots, systemName); + const allSnapshots = reportStore.listSnapshots(readStorage, 500); + const pad = (n) => n.toString().padStart(2, "0"); + const daySnaps = (allSnapshots || []).filter((s) => { + const d = new Date(s.timestamp); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` === report.date; + }); + filePath = await reportExport.exportDailyReportPdf(app, report, null, daySnaps, systemName); } else { return { ok: false, error: "未知 mode" }; } @@ -887,6 +1151,7 @@ async function handleApi(pathname, method, body, query) { // ─── 主入口 ──── async function start(options) { _storage = options?.storage; + _onSettingsChanged = options?.onSettingsChanged; // 读配置 let enabled = false; @@ -1000,12 +1265,12 @@ async function start(options) { return; } - // 读 dist/index.html 并在 前注入 token(唯一一处,避免破坏其他标签) + // 读 dist/index.html 并在 前注入 token + 同步设置预加载 + // 同步 XHR 确保 React SPA 启动前 localStorage 已有权威设置值 try { const htmlPath = path.join(resolveDistDir(), "index.html"); let html = fs.readFileSync(htmlPath, "utf-8"); - const inject = ``; - // 只注入一处: 前最稳妥(Vite 模板里 只有一处) + const inject = ``; html = html.replace("", inject + ""); sendHtml(res, 200, html); } catch (e) { @@ -1017,12 +1282,14 @@ async function start(options) { return new Promise((resolve) => { _server.listen(_port, "0.0.0.0", () => { - const url = `http://0.0.0.0:${_port}/?token=${_token}`; + const lanIp = getLanIp(); + const url = `http://${lanIp}:${_port}/?token=${_token}`; console.log("[RemoteHTTP] ✅ 远程 HTTP 服务已启动"); console.log("[RemoteHTTP] 监听端口:", _port); - console.log("[RemoteHTTP] 访问 URL:", url); + console.log("[RemoteHTTP] 本机 URL (localhost):", `http://localhost:${_port}/?token=${_token}`); + console.log("[RemoteHTTP] 局域网 URL:", url); console.log("[RemoteHTTP] dist 目录:", resolveDistDir()); - resolve({ started: true, port: _port, token: _token, url }); + resolve({ started: true, port: _port, token: _token, url, lanIp }); }); _server.on("error", (err) => { console.error("[RemoteHTTP] 启动失败:", err.message); @@ -1044,10 +1311,13 @@ function stop() { } function getStatus() { + const lanIp = _server ? getLanIp() : null; return { running: !!_server, port: _port, token: _token, + lanIp, + url: lanIp && _token ? `http://${lanIp}:${_port}/?token=${_token}` : null, }; } diff --git a/index.html b/index.html index 506d77c..792b9ca 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,26 @@ + + NetMesh SSH diff --git a/infrastructure/config/storageKeys.ts b/infrastructure/config/storageKeys.ts index 762ccd1..d6e2c3c 100644 --- a/infrastructure/config/storageKeys.ts +++ b/infrastructure/config/storageKeys.ts @@ -2,6 +2,8 @@ export const STORAGE_KEY_PRODUCT_NAME = 'netcatty_product_name_v1'; /** User-editable topology monitoring dashboard title. */ export const STORAGE_KEY_MONITORING_DASHBOARD_TITLE = 'netcatty_monitoring_dashboard_title_v1'; +/** User-selected topology map background filename (relative to userData/monitoring/maps). */ +export const STORAGE_KEY_MONITORING_MAP_BG_SELECTED = 'netcatty_monitoring_map_bg_selected_v1'; /** User-editable product name shown in inspection PDF reports. */ export const STORAGE_KEY_REPORT_PRODUCT_NAME = 'netcatty_report_product_name_v1'; diff --git a/infrastructure/inspection/inspectionApi.ts b/infrastructure/inspection/inspectionApi.ts index bd9509d..0d6f5c2 100644 --- a/infrastructure/inspection/inspectionApi.ts +++ b/infrastructure/inspection/inspectionApi.ts @@ -138,31 +138,44 @@ const REMOTE_API_MAP: Record { if (name === "schedulerStatus" || name === "isReportSnapshotRunning") return { running: false }; return null; } - let url = `${mapping.path}?token=${encodeURIComponent(REMOTE_TOKEN)}`; + let url = mapping.path; + // 告警状态变更:把 __ID__ 替换成实际 alertId + if (url.includes("__ID__") && args[0]) { + url = url.replace("__ID__", encodeURIComponent(args[0])); + args = args.slice(1); // alertId 从参数里移除,剩下的是 payload + } + url += `?token=${encodeURIComponent(REMOTE_TOKEN)}`; if (name === "getLatestByDevice" && args[0]) { url += `&device_id=${encodeURIComponent(args[0])}`; } + if (name === "getResultsByTimeRange" && args[0] !== undefined && args[1] !== undefined) { + url += `&start=${encodeURIComponent(args[0])}&end=${encodeURIComponent(args[1])}`; + } 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}`; @@ -204,6 +226,24 @@ async function remoteFetch(name: string, ...args: any[]): Promise { } else if (name === "generateDailyReport") { // invoke("generateDailyReport", options) fetchOpts.body = JSON.stringify(args[0] || {}); + } else if (name === "createRule") { + fetchOpts.body = JSON.stringify({ action: "create", data: args[0] || {} }); + } else if (name === "updateRule") { + fetchOpts.body = JSON.stringify({ action: "update", id: args[0], updates: args[1] || {} }); + } else if (name === "deleteRule") { + fetchOpts.body = JSON.stringify({ action: "delete", id: args[0] }); + } else if (name === "toggleRule") { + fetchOpts.body = JSON.stringify({ action: "toggle", id: args[0], enabled: !!args[1] }); + } else if (name === "toggleReportScheduler") { + fetchOpts.body = JSON.stringify({ enabled: !!args[0] }); + } else if (name === "acknowledgeAlert") { + fetchOpts.body = JSON.stringify({ operator: args[0] || "remote" }); + } else if (name === "resolveAlert") { + fetchOpts.body = JSON.stringify({ reason: args[0] || "manual", operator: args[1] || "remote" }); + } else if (name === "closeAlert") { + fetchOpts.body = JSON.stringify({ note: args[0] || "" }); + } else if (name === "updateAlertNote") { + fetchOpts.body = JSON.stringify({ note: args[0] || "" }); } else { fetchOpts.body = JSON.stringify(args[0] !== undefined ? args[0] : {}); } @@ -213,27 +253,27 @@ async function remoteFetch(name: string, ...args: any[]): Promise { // 适配不同返回结构 if (data && typeof data === "object") { if (Array.isArray(data)) return data; - // 优先用 arrayField 约定的字段 + // getAllExtensions:必须把 devices 数组转成 Record + if (name === "getAllExtensions" && Array.isArray(data.devices)) { + const map: Record = {}; + for (const d of data.devices) { + if (d?.host_id) map[d.host_id] = d; + } + return map; + } + // reportSchedulerStatus: { running, schedule } + if (name === "reportSchedulerStatus" && "running" in data) return data; + // 优先用 arrayField 约定的字段(getAllExtensions 已在上文处理 Record 转换) 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.devices !== undefined) 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.schedule !== undefined && (name === "getReportSchedule" || name === "saveReportSchedule")) return data.schedule; if (data.ok === true && data.data !== undefined) return data.data; if ("running" in data) return data; // schedulerStatus / snapshot-status return data; @@ -315,6 +355,8 @@ export const inspectionResultApi = { invoke("getLatestByDevice", hostId, limit), getRecent: (limit = 50): Promise => invoke("getRecentResults", limit), + getByTimeRange: (startTime: number, endTime: number): Promise => + invoke("getResultsByTimeRange", startTime, endTime), }; // ---------- 告警记录 ---------- diff --git a/infrastructure/persistence/localStorageAdapter.ts b/infrastructure/persistence/localStorageAdapter.ts index cb603ad..ad36e98 100644 --- a/infrastructure/persistence/localStorageAdapter.ts +++ b/infrastructure/persistence/localStorageAdapter.ts @@ -9,6 +9,10 @@ const safeParse = (value: string | null): T | null => { export const LOCAL_STORAGE_ADAPTER_CHANGED_EVENT = 'netcatty:local-storage-adapter-changed'; +// 全局钩子:写后调用。AppSideEffects 注册后用来同步权威源。 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).__NETCATTY_SETTINGS_SYNC__ = null as null | ((key: string, value: string | null) => void); + const pendingChangedKeys = new Set(); let emitChangedKeysTimer: ReturnType | null = null; @@ -55,6 +59,8 @@ function safeSetItem(key: string, value: string): boolean { } localStorage.setItem(key, value); emitLocalStorageAdapterChanged(key); + // 同步到权威源(如果钩子已注册) + try { (globalThis as any).__NETCATTY_SETTINGS_SYNC__?.(key, value); } catch {} return true; } catch (err) { if ( @@ -107,5 +113,7 @@ export const localStorageAdapter = { if (localStorage.getItem(key) === null) return; localStorage.removeItem(key); emitLocalStorageAdapterChanged(key); + // 同步到权威源(删除) + try { (globalThis as any).__NETCATTY_SETTINGS_SYNC__?.(key, null); } catch {} }, }; diff --git a/package-lock.json b/package-lock.json index 2a2f3bc..e312bdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "ajv": "8.18.0", "ajv-formats": "3.0.1", "clsx": "2.1.1", + "docx": "^9.13.0", "echarts": "^6.1.0", "electron-updater": "^6.8.3", "http-proxy-agent": "^7.0.2", @@ -68,6 +69,7 @@ "micromark-extension-gfm-strikethrough": "^2.1.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", + "pend": "^1.2.0", "pinyin-pro": "^3.28.1", "re2js": "2.8.6", "react": "^19.2.1", @@ -10645,7 +10647,6 @@ "version": "1.0.3", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -11034,6 +11035,54 @@ "js-yaml": "^4.1.0" } }, + "node_modules/docx": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/docx/-/docx-9.13.0.tgz", + "integrity": "sha512-MofrvX7rzwCe1xUFXnwneQHzv2p1lIUIDPD01DueOnKGw+TSKxAAcK5Beo69bTyqKUYsX8d49Co03Bcntc60WQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^26.6.2", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^6.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.2.tgz", + "integrity": "sha512-hIvVOb9Qr1p/9CNNLOe0OlWvgx/ziTsVwKN/GDSsuArN2K0wDiafP3s0SWmsm7o2BdnyS8Z9Z64NFBUbn6ziBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^22 || ^24 || >=26" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz", @@ -12691,6 +12740,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", @@ -13086,6 +13145,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.9", "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz", @@ -13617,6 +13682,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/katex": { "version": "0.18.11", "resolved": "https://registry.npmmirror.com/katex/-/katex-0.18.11.tgz", @@ -13736,6 +13849,15 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", @@ -15262,6 +15384,12 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz", @@ -15772,6 +15900,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", @@ -15981,6 +16115,12 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -16148,7 +16288,6 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -17240,6 +17379,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index ee091b9..6cad487 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "ajv": "8.18.0", "ajv-formats": "3.0.1", "clsx": "2.1.1", + "docx": "^9.13.0", "echarts": "^6.1.0", "electron-updater": "^6.8.3", "http-proxy-agent": "^7.0.2",