diff --git a/application/state/settingsIpcSync.ts b/application/state/settingsIpcSync.ts index eb3c380..d402cb6 100644 --- a/application/state/settingsIpcSync.ts +++ b/application/state/settingsIpcSync.ts @@ -49,6 +49,7 @@ import { STORAGE_KEY_WINDOW_OPACITY, STORAGE_KEY_APP_ICON_VARIANT, STORAGE_KEY_HTTP_NETWORK_PROXY, + STORAGE_KEY_PRODUCT_NAME, } from '../../infrastructure/config/storageKeys'; import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant'; import { @@ -71,6 +72,7 @@ interface UseSettingsIpcSyncParams { syncCustomCssFromStorage: () => void; setUiLanguage: Dispatch>; setUiFontFamilyId: Dispatch>; + setProductName: Dispatch>; setTerminalThemeId: Dispatch>; setTerminalThemeDarkId: Dispatch>; setTerminalThemeLightId: Dispatch>; @@ -117,6 +119,7 @@ export function useSettingsIpcSync({ syncCustomCssFromStorage, setUiLanguage, setUiFontFamilyId, + setProductName, setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId, @@ -186,6 +189,9 @@ export function useSettingsIpcSync({ setUiFontFamilyId(value); } } + if (key === STORAGE_KEY_PRODUCT_NAME && typeof value === 'string') { + setProductName(value || 'NetMesh'); + } if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') { setTerminalThemeId(value); } diff --git a/application/state/useInspectionScheduler.ts b/application/state/useInspectionScheduler.ts new file mode 100644 index 0000000..01e058a --- /dev/null +++ b/application/state/useInspectionScheduler.ts @@ -0,0 +1,317 @@ +/** + * 巡检调度器 Hook(前端侧) + * + * 为什么调度器在前端?因为 vault 主机数据在渲染进程的 localStorage 里, + * 主进程拿不到完整的主机连接信息(密码/私钥)。 + * + * 调度器职责: + * - 每 30 秒检查一次哪些设备到期需要巡检 + * - 按并发限制依次调用主进程的 inspection:runInspection 执行 + * - 维护运行状态、上次执行时间 + */ + +import { useRef, useCallback, useEffect, useState, useMemo } from "react"; +import { inspectionExecApi, inspectionDeviceApi } from "../../infrastructure/inspection/inspectionApi"; +import type { Host } from "../../domain/models"; +import type { SSHKey } from "../../domain/models/key"; +import type { DeviceExtension } from "../../infrastructure/inspection/inspectionApi"; + +const POLL_INTERVAL_MS = 30 * 1000; // 30 秒轮询 +const DEFAULT_CONCURRENCY = 3; // 默认并发数 +const SYS_DEFAULT_INTERVAL = 5; // 系统默认周期(分钟) + +const TIER_DEFAULT_INTERVAL: Record = { + core: 5, + aggregation: 10, + access: 15, +}; + +export interface SchedulerStatus { + running: boolean; + totalDevices: number; + activeDevices: number; + lastScanAt: number | null; + runningCount: number; +} + +interface LastRunInfo { + [hostId: string]: number; // timestamp +} + +/** + * 计算设备的有效巡检周期(分钟) + * 优先级:设备自定义 > 分级默认 > 系统默认 + */ +function getEffectiveInterval(ext: DeviceExtension | null): number { + if (!ext || !ext.inspection_enabled) return 0; + if (ext.inspection_interval && ext.inspection_interval > 0) { + return ext.inspection_interval; + } + if (ext.tier && TIER_DEFAULT_INTERVAL[ext.tier]) { + return TIER_DEFAULT_INTERVAL[ext.tier]; + } + return SYS_DEFAULT_INTERVAL; +} + +/** + * 从 Host + keys 拼出巡检需要的连接配置 + */ +function buildHostConfig(host: Host, keys: SSHKey[]): { + id: string; + label: string; + hostname: string; + port: number; + username: string; + password?: string; + privateKeyContent?: string; + passphrase?: string; +} { + const config: any = { + id: host.id, + label: host.label, + hostname: host.hostname, + port: host.port || 22, + username: host.username, + }; + + // 密码 + if (host.password) { + config.password = host.password; + } + + // 私钥(通过 identityFileId 关联) + if (host.identityFileId) { + const key = keys.find((k) => k.id === host.identityFileId); + if (key && key.privateKey) { + config.privateKeyContent = key.privateKey; + if (key.passphrase) { + config.passphrase = key.passphrase; + } + } + } + + return config; +} + +export function useInspectionScheduler(hosts: Host[], keys: SSHKey[]) { + const [status, setStatus] = useState({ + running: false, + totalDevices: 0, + activeDevices: 0, + lastScanAt: null, + runningCount: 0, + }); + + const timerRef = useRef(null); + const lastRunRef = useRef({}); + const runningRef = useRef(false); + const runningCountRef = useRef(0); + const hostsRef = useRef(hosts); + const keysRef = useRef(keys); + + // 实时更新 hosts/keys 引用 + useEffect(() => { + hostsRef.current = hosts; + }, [hosts]); + + useEffect(() => { + keysRef.current = keys; + }, [keys]); + + /** + * 执行一次单设备巡检 + */ + const runDeviceInspection = useCallback( + async (hostId: string, catalogId?: string) => { + const host = hostsRef.current.find((h) => h.id === hostId); + if (!host) { + console.warn(`[InspectionScheduler] 未找到主机: ${hostId}`); + return null; + } + + const hostConfig = buildHostConfig(host, keysRef.current); + const actualCatalogId = catalogId || "linux_server"; + + runningCountRef.current += 1; + setStatus((s) => ({ ...s, runningCount: runningCountRef.current })); + + try { + const result = await inspectionExecApi.runInspection( + hostId, + hostConfig, + actualCatalogId + ); + lastRunRef.current[hostId] = Date.now(); + return result; + } catch (e: any) { + console.error(`[InspectionScheduler] 巡检失败 ${hostId}:`, e.message); + return { success: false, error: e.message }; + } finally { + runningCountRef.current = Math.max(0, runningCountRef.current - 1); + setStatus((s) => ({ ...s, runningCount: runningCountRef.current })); + } + }, + [] + ); + + /** + * 主循环:检查到期设备并执行 + */ + const scanLoop = useCallback(async (hosts?: typeof hosts) => { + // 如果还没启动,自动启动调度器 + if (!runningRef.current) { + runningRef.current = true; + setStatus((s) => ({ ...s, running: true })); + console.log("[InspectionScheduler] 调度器自动启动"); + } + // 使用传入的 hosts 参数,或者用 ref + const currentHosts = hosts ?? hostsRef.current; + // hosts 还没加载,跳过 + if (!currentHosts || currentHosts.length === 0) { + console.log('[InspectionScheduler] hosts 还未加载,当前 hosts:', currentHosts?.length); + return; + } + console.log('[InspectionScheduler] 开始巡检,设备数:', currentHosts.length); + + const allExts = await inspectionDeviceApi.getAll(); + const extsMap = allExts || {}; // Record + + // 调试:统计启用了巡检的设备数量 + let enabledCount = 0; + for (const host of currentHosts) { + const ext = extsMap[host.id]; + if (ext?.inspection_enabled) enabledCount++; + } + console.log('[InspectionScheduler] 扩展配置数量:', Object.keys(extsMap).length, '| 启用巡检设备数:', enabledCount); + + const now = Date.now(); + const dueDevices: { hostId: string; catalogId: string }[] = []; + + for (const host of currentHosts) { + const ext = extsMap[host.id]; + if (!ext || !ext.inspection_enabled) continue; + + const intervalMin = getEffectiveInterval(ext); + if (intervalMin <= 0) { + console.log('[InspectionScheduler] 跳过(周期为0):', host.label, '| intervalMin:', intervalMin); + continue; + } + + const lastRun = lastRunRef.current[host.id] || ext.last_scan_at || 0; + const intervalMs = intervalMin * 60 * 1000; + const elapsed = now - lastRun; + const needRun = elapsed >= intervalMs; + + console.log('[InspectionScheduler] 设备:', host.label, '| 周期:', intervalMin, 'min | 距上次:', Math.round(elapsed/60000), 'min | 需要巡检:', needRun); + + if (needRun) { + dueDevices.push({ + hostId: host.id, + catalogId: ext.catalog_id || "linux_server", + }); + } + } + + console.log('[InspectionScheduler] 到期设备数:', dueDevices.length); + if (dueDevices.length === 0) { + setStatus((s) => ({ ...s, lastScanAt: now })); + return; + } + + // 并发执行(简单的并发控制) + const queue = [...dueDevices]; + const workers: Promise[] = []; + + const processQueue = async () => { + while (queue.length > 0) { + const device = queue.shift(); + if (!device) break; + await runDeviceInspection(device.hostId, device.catalogId); + } + }; + + for (let i = 0; i < Math.min(DEFAULT_CONCURRENCY, dueDevices.length); i++) { + workers.push(processQueue()); + } + + await Promise.all(workers); + setStatus((s) => ({ ...s, lastScanAt: now })); + }, [runDeviceInspection]); + + /** + * 启动调度器 + */ + const start = useCallback(() => { + if (runningRef.current) return; + runningRef.current = true; + + // 立即执行一次 + scanLoop(); + + // 定时轮询 + timerRef.current = setInterval(() => { + scanLoop(); + }, POLL_INTERVAL_MS); + + setStatus((s) => ({ ...s, running: true })); + console.log("[InspectionScheduler] 已启动"); + }, [scanLoop]); + + /** + * 停止调度器 + */ + const stop = useCallback(() => { + runningRef.current = false; + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setStatus((s) => ({ ...s, running: false })); + console.log("[InspectionScheduler] 已停止"); + }, []); + + /** + * 立即巡检指定设备 + */ + const scanNow = useCallback( + async (hostId: string, catalogId?: string) => { + return await runDeviceInspection(hostId, catalogId); + }, + [runDeviceInspection] + ); + + // 组件卸载时清理 + useEffect(() => { + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + runningRef.current = false; + }; + }, []); + + // 计算状态 + useEffect(() => { + const activeCount = hosts.filter((h) => { + // 这里没法同步拿到 extensions,先按 hosts 总数显示 + return true; + }).length; + + setStatus((s) => ({ + ...s, + totalDevices: hosts.length, + activeDevices: activeCount, + })); + }, [hosts]); + + return useMemo( + () => ({ + status, + start, + stop, + scanNow, + scanLoop, + }), + [status, start, stop, scanNow, scanLoop] + ); +} diff --git a/application/state/useServerStats.ts b/application/state/useServerStats.ts index abc4e1c..569cd1f 100644 --- a/application/state/useServerStats.ts +++ b/application/state/useServerStats.ts @@ -50,6 +50,11 @@ export interface ServerStats { latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint netInterfaces: NetInterfaceInfo[]; // Per-interface network stats lastUpdated: number | null; // Timestamp of last successful update + // Network-device extended fields (H3C / Huawei / Cisco PTY-shell collector). + // POSIX / Windows collectors never populate these. + networkDevicePorts?: { name: string; phy: string }[]; + networkDevicePortUp?: number; + networkDevicePortDown?: number; } interface UseServerStatsOptions { @@ -217,6 +222,10 @@ function normalizeServerStats(stats: Partial): ServerStats { latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null, netInterfaces: stats.netInterfaces || [], lastUpdated: Date.now(), + // Network-device extended fields — passthrough from backend PTY-shell collector + networkDevicePorts: Array.isArray(stats.networkDevicePorts) ? stats.networkDevicePorts : undefined, + networkDevicePortUp: stats.networkDevicePortUp, + networkDevicePortDown: stats.networkDevicePortDown, }; } diff --git a/application/state/useSettingsState.ts b/application/state/useSettingsState.ts index 6821a05..7690efb 100644 --- a/application/state/useSettingsState.ts +++ b/application/state/useSettingsState.ts @@ -84,6 +84,7 @@ import { STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, + STORAGE_KEY_PRODUCT_NAME, } from '../../infrastructure/config/storageKeys'; import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n'; import { @@ -272,6 +273,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY); return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID; }); + // 产品名称配置(用于标题栏、侧边栏 Logo 等) + const [productName, setProductName] = useState(() => { + return localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh'; + }); const [syncConfig, setSyncConfig] = useState(() => localStorageAdapter.read(STORAGE_KEY_SYNC)); const [terminalThemeId, setTerminalThemeId] = useState(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME); const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState(() => { @@ -1163,12 +1168,32 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable } }, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]); + // Persist product name + useLayoutEffect(() => { + localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, productName); + if (persistMountedRef.current) { + notifySettingsChanged(STORAGE_KEY_PRODUCT_NAME, productName); + // 更新窗口标题栏 + const bridge = netcattyBridge.get(); + if (bridge?.setWindowTitle) { + bridge.setWindowTitle(productName).then((result: any) => { + console.log('[productName] setWindowTitle result:', result); + }).catch((err: any) => { + console.log('[productName] setWindowTitle error:', err); + }); + } + } + // 发送自定义事件通知侧边栏更新 + window.dispatchEvent(new CustomEvent('productNameChanged', { detail: productName })); + }, [productName, notifySettingsChanged]); + useSettingsIpcSync({ enabled: enableSettingsSync, syncAppearanceFromStorage, syncCustomCssFromStorage, setUiLanguage, setUiFontFamilyId, + setProductName, setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId, @@ -2004,6 +2029,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable setCustomAccent, uiFontFamilyId, setUiFontFamilyId, + productName, + setProductName, syncConfig, updateSyncConfig, uiLanguage, diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts index 337d59d..b06e5b6 100644 --- a/application/state/useVaultState.ts +++ b/application/state/useVaultState.ts @@ -1490,9 +1490,19 @@ export const useVaultState = () => { const savedNoteGroups = localStorageAdapter.read(STORAGE_KEY_NOTE_GROUPS); if (savedSnippets) { - const orderedSnippets = normalizeVaultOrder(savedSnippets); - snippetsRef.current = orderedSnippets; - setSnippets(orderedSnippets); + // Merge any INITIAL_SNIPPETS entries the user doesn't have yet. + // This lets pre-built script catalogs reach existing installations + // without requiring users to clear storage. + const existingIds = new Set(savedSnippets.map((s) => s.id)); + const missing = INITIAL_SNIPPETS.filter((s) => !existingIds.has(s.id)); + const merged = missing.length > 0 + ? normalizeVaultOrder([...savedSnippets, ...missing]) + : normalizeVaultOrder(savedSnippets); + if (missing.length > 0) { + localStorageAdapter.write(STORAGE_KEY_SNIPPETS, merged); + } + snippetsRef.current = merged; + setSnippets(merged); // Persist order backfill only when fields changed. Never rewrite the // pre-lock snapshot unlocked: another renderer can delete under the // vault lock after this read and before this write, resurrecting diff --git a/build/icons/128x128.png b/build/icons/128x128.png index 5f392ce..f738fd9 100644 Binary files a/build/icons/128x128.png and b/build/icons/128x128.png differ diff --git a/build/icons/16x16.png b/build/icons/16x16.png index f4092c3..c29b3e8 100644 Binary files a/build/icons/16x16.png and b/build/icons/16x16.png differ diff --git a/build/icons/256x256.png b/build/icons/256x256.png index d0a8448..e1db12e 100644 Binary files a/build/icons/256x256.png and b/build/icons/256x256.png differ diff --git a/build/icons/32x32.png b/build/icons/32x32.png index 656bfcd..28037a4 100644 Binary files a/build/icons/32x32.png and b/build/icons/32x32.png differ diff --git a/build/icons/48x48.png b/build/icons/48x48.png index 235d695..231c06d 100644 Binary files a/build/icons/48x48.png and b/build/icons/48x48.png differ diff --git a/build/icons/512x512.png b/build/icons/512x512.png index f513d4c..592666d 100644 Binary files a/build/icons/512x512.png and b/build/icons/512x512.png differ diff --git a/build/icons/64x64.png b/build/icons/64x64.png index cee6f4e..34734ab 100644 Binary files a/build/icons/64x64.png and b/build/icons/64x64.png differ diff --git a/build/installer.nsh b/build/installer.nsh index 2cbc89b..93aa1ae 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -1,19 +1,19 @@ !macro customInstall - ; Open a selected folder in a new Netcatty local terminal. + ; Open a selected folder in a new NetMesh local terminal. ; Args go after `--` so Electron/Chromium does not swallow them. Use ; `--open-terminal-path="%1."` so paths with spaces stay one token and drive ; roots (`C:\`) do not break Windows quote parsing. - WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty" "MUIVerb" "Open in Netcatty" - WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0" - WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%1."' + WriteRegStr SHCTX "Software\Classes\Directory\shell\NetMesh" "MUIVerb" "在 NetMesh 中打开终端" + WriteRegStr SHCTX "Software\Classes\Directory\shell\NetMesh" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0" + WriteRegStr SHCTX "Software\Classes\Directory\shell\NetMesh\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%1."' ; Open the directory currently displayed by Explorer. - WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "MUIVerb" "Open in Netcatty" - WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0" - WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%V."' + WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\NetMesh" "MUIVerb" "在 NetMesh 中打开终端" + WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\NetMesh" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0" + WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\NetMesh\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%V."' !macroend !macro customUnInstall - DeleteRegKey SHCTX "Software\Classes\Directory\shell\Netcatty" - DeleteRegKey SHCTX "Software\Classes\Directory\Background\shell\Netcatty" + DeleteRegKey SHCTX "Software\Classes\Directory\shell\NetMesh" + DeleteRegKey SHCTX "Software\Classes\Directory\Background\shell\NetMesh" !macroend diff --git a/components/HostDetailsPanel.tsx b/components/HostDetailsPanel.tsx index 98f64e7..03f459e 100644 --- a/components/HostDetailsPanel.tsx +++ b/components/HostDetailsPanel.tsx @@ -41,6 +41,8 @@ import { type AsidePanelResizeProps, } from "./ui/aside-panel"; import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections"; +import { HostInspectionSection } from "./inspection/HostInspectionSection"; +import { InspectionErrorBoundary } from "./inspection/InspectionErrorBoundary"; import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections"; import { LINUX_DISTRO_OPTION_IDS, @@ -1157,6 +1159,18 @@ const HostDetailsPanel: React.FC = ({ clearProxyConfig={clearProxyConfig} /> + {/* 巡检配置区 - 仅编辑已有主机时显示 */} + {initialData?.id && ( + + + + )} +
{t("hostDetails.otherProtocols")} diff --git a/components/SettingsApplicationTab.tsx b/components/SettingsApplicationTab.tsx index 78bb3fa..23be89b 100644 --- a/components/SettingsApplicationTab.tsx +++ b/components/SettingsApplicationTab.tsx @@ -1,9 +1,8 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { ArrowUpCircle, Bug, Check, Github, Loader2, MessageCircle, Newspaper, RefreshCcw } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { ArrowUpCircle, Check, Loader2, RefreshCcw } from "lucide-react"; import AppLogo from "./AppLogo"; import AppWordmark from "./AppWordmark"; import { Button } from "./ui/button"; -import { cn } from "../lib/utils"; import { useApplicationBackend } from "../application/state/useApplicationBackend"; import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck"; import { useI18n } from "../application/i18n/I18nProvider"; @@ -16,75 +15,6 @@ type AppInfo = { platform?: string; }; -const REPO_URL = "https://github.com/binaricat/Netcatty"; -const BUG_REPORT_TEMPLATE = "bug_report.yml"; - -const mapIssuePlatform = (platform?: string) => { - switch (platform) { - case "darwin": - return "macOS"; - case "win32": - return "Windows"; - case "linux": - return "Linux"; - default: - return undefined; - } -}; - -/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */ -export const buildIssueUrl = (appInfo: AppInfo) => { - const params = new URLSearchParams({ - template: BUG_REPORT_TEMPLATE, - title: "[Bug] ", - }); - - if (appInfo.version) { - params.set("version", appInfo.version); - } - - const platform = mapIssuePlatform(appInfo.platform); - if (platform) { - params.set("platform", platform); - } - - const installSource = - appInfo.version === "0.0.0" - ? "Built from source (npm run dev / pack)" - : "GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)"; - params.set("install_source", installSource); - - const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown"; - params.set( - "logs", - `Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`, - ); - - return `${REPO_URL}/issues/new?${params.toString()}`; -}; - -const ActionRow: React.FC<{ - icon: React.ReactNode; - title: string; - subtitle: string; - onClick: () => void; -}> = ({ icon, title, subtitle, onClick }) => ( - -); - interface SettingsApplicationTabProps { updateState: UpdateState; checkNow: UseUpdateCheckResult['checkNow']; @@ -92,9 +22,11 @@ interface SettingsApplicationTabProps { installUpdate: UseUpdateCheckResult['installUpdate']; startDownload: UseUpdateCheckResult['startDownload']; isUpdateDemoMode: boolean; + productName: string; + setProductName: (value: string) => void; } -export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) { +export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode, productName, setProductName }: SettingsApplicationTabProps) { const { t } = useI18n(); const { openExternal, getApplicationInfo } = useApplicationBackend(); const [appInfo, setAppInfo] = useState({ name: "Netcatty", version: "" }); @@ -163,10 +95,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele setTimeout(() => setLastCheckResult('none'), 3000); }; - const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]); - const releasesUrl = `${REPO_URL}/releases`; - const discussionsUrl = `${REPO_URL}/discussions`; - return (
@@ -174,7 +102,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
- +
{appInfo.version ? appInfo.version : " "} @@ -224,43 +152,35 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele } -
-
-
- - } - title={t("settings.application.reportProblem")} - subtitle={t("settings.application.reportProblem.subtitle")} - onClick={() => void handleOpenExternal(issueUrl)} + {/* 产品名称配置 */} + +
+ + setProductName(e.target.value)} + onBlur={() => { + // 失焦时确保标题栏更新 + const bridge = (window as any).netcatty; + bridge?.setWindowTitle?.(productName); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + }} + placeholder="NetMesh" + className="w-full px-3 py-2 rounded-lg border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring" /> - - - } - title={t("settings.application.community")} - subtitle={t("settings.application.community.subtitle")} - onClick={() => void handleOpenExternal(discussionsUrl)} - /> - - - } - title="GitHub" - subtitle={t("settings.application.github.subtitle")} - onClick={() => void handleOpenExternal(REPO_URL)} - /> - - - } - title={t("settings.application.whatsNew")} - subtitle={t("settings.application.whatsNew.subtitle")} - onClick={() => void handleOpenExternal(releasesUrl)} - /> - -
+

+ {t('settings.application.productNameHint') || 'Used in title bar, sidebar, and settings'} +

+
+
diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index d01b865..16031a3 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -2,16 +2,13 @@ * Settings Page - Standalone settings window content * This component is rendered in a separate Electron window */ -import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react"; +import { AppWindow, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react"; import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { useAISettingsState } from "../application/state/useAISettingsState"; import { useAvailableFonts } from "../application/state/fontStore"; -import { usePortForwardingState } from "../application/state/usePortForwardingState"; -import { useVaultState } from "../application/state/useVaultState"; import { useWindowControls } from "../application/state/useWindowControls"; import { useUpdateCheck } from "../application/state/useUpdateCheck"; import { I18nProvider, useI18n } from "../application/i18n/I18nProvider"; -import { sanitizePortForwardingRulesForSync } from "../application/syncPayload"; import type { AppLockGateRenderContext } from "./AppLockGate"; import { toast } from "./ui/toast"; import { SettingsTabContent } from "./settings/settings-ui"; @@ -33,9 +30,8 @@ const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsApp const LazySettingsFileAssociationsTab = lazy(() => import("./settings/tabs/SettingsFileAssociationsTab")); const LazySettingsShortcutsTab = lazy(() => import("./settings/tabs/SettingsShortcutsTab")); const LazySettingsAITab = lazy(() => import("./settings/tabs/SettingsAITab")); -const LazySettingsSyncTab = lazy(() => import("./settings/tabs/SettingsSyncTab")); -const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab")); const LazySettingsSystemTab = lazy(() => import("./settings/tabs/SettingsSystemTab")); +const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab")); const LazySettingsPluginsTab = lazy(() => import("./settings/tabs/SettingsPluginsTab")); const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); @@ -237,72 +233,6 @@ const SettingsAITabContainer: React.FC = () => { ); }; -const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => { - const { - hosts, - keys, - identities, - proxyProfiles, - snippets, - customGroups, - snippetPackages, - notes, - noteGroups, - knownHosts, - groupConfigs, - importDataFromString, - clearVaultData, - } = useVaultState(); - - const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState(); - - // Strip transient runtime fields before passing to sync - const portForwardingRulesForSync = useMemo( - () => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [], - [portForwardingRules], - ); - - const vault = useMemo( - () => ({ - hosts, - keys, - identities, - proxyProfiles, - snippets, - customGroups, - snippetPackages, - notes, - noteGroups, - knownHosts, - groupConfigs, - }), - [ - hosts, - keys, - identities, - proxyProfiles, - snippets, - customGroups, - snippetPackages, - notes, - noteGroups, - knownHosts, - groupConfigs, - ], - ); - - return ( - - ); -}; - const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => { const { t } = useI18n(); const { request, clearFocus, openSearch } = useSettingsFocus(); @@ -488,13 +418,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock {t("settings.tab.ai")} - - - {t("settings.tab.syncCloud")} - )} @@ -619,12 +544,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock )} - {mountedTabs.has("sync") && ( - - - - )} - {mountedTabs.has("system") && ( )} diff --git a/components/VaultView.tsx b/components/VaultView.tsx index 0f9d954..9e50275 100644 --- a/components/VaultView.tsx +++ b/components/VaultView.tsx @@ -156,6 +156,7 @@ import { VaultViewLayout } from "./vault/VaultViewLayout"; import { useVaultHostCollections } from "./vault/useVaultHostCollections"; import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers"; import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers"; +import { useInspectionScheduler } from "../application/state/useInspectionScheduler"; const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog")); const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager")); @@ -172,7 +173,10 @@ export type VaultSection = | "notes" | "port" | "knownhosts" - | "logs"; + | "logs" + | "inspection" + | "alerts" + | "alertRules"; const haveSameHostOrderResult = (previous: Host[], next: Host[]) => { if (previous.length !== next.length) return false; @@ -298,6 +302,7 @@ interface VaultViewProps { keepaliveInterval: number; keepaliveCountMax: number; }; + productName?: string; } const VaultViewInner: React.FC = ({ @@ -327,6 +332,7 @@ const VaultViewInner: React.FC = ({ onUpdateKeys, onImportOrReuseKey, onUpdateIdentities, + productName, onUpdateProxyProfiles, onUpdateSnippets, onUpdateSnippetPackages, @@ -429,6 +435,32 @@ const VaultViewInner: React.FC = ({ } }, [navigateToSection, onNavigateToSectionHandled]); + // 巡检调度器 + const inspectionScheduler = useInspectionScheduler(hosts, keys); + + // 监听 hosts 加载完成后触发一次巡检(StrictMode 下多次触发也没关系,scanLoop 有 runningRef 保护) + const prevHostsLenRef = useRef(0); + useEffect(() => { + if (hosts.length > 0 && prevHostsLenRef.current === 0) { + // hosts 从空变成非空,触发一次巡检(传入 hosts 参数,避免闭包问题) + console.log('[VaultView] hosts 加载完成,触发 scanLoop'); + setTimeout(() => { + inspectionScheduler.scanLoop(hosts); + }, 500); + } + prevHostsLenRef.current = hosts.length; + }, [hosts.length]); + + // 启动周期性巡检(30 秒一次) + useEffect(() => { + if (hosts.length === 0) return; + const id = setInterval(() => { + console.log('[VaultView] 定时触发 scanLoop'); + inspectionScheduler.scanLoop(hosts); + }, 30000); + return () => clearInterval(id); + }, [hosts.length]); + // View mode, sorting, and tag filter state const [viewMode, setViewMode] = useStoredViewMode( STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, @@ -1532,6 +1564,7 @@ const VaultViewInner: React.FC = ({ protocolSelectHost, proxyProfiles, ProxyProfilesManager, + productName, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, @@ -1684,7 +1717,8 @@ export const vaultViewAreEqual = ( prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval && prev.terminalSettings?.keepaliveCountMax === - next.terminalSettings?.keepaliveCountMax; + next.terminalSettings?.keepaliveCountMax && + prev.productName === next.productName; return isEqual; }; diff --git a/components/inspection/AlertCenterPage.tsx b/components/inspection/AlertCenterPage.tsx new file mode 100644 index 0000000..4d54f88 --- /dev/null +++ b/components/inspection/AlertCenterPage.tsx @@ -0,0 +1,528 @@ +import { + AlertTriangle, + CheckCircle, + XCircle, + Clock, + MessageSquare, + RefreshCw, + Filter, + Info, + AlertOctagon, + Bell, + ChevronRight, +} from "lucide-react"; +import React, { useEffect, useState, useCallback } from "react"; +import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; +import { Input } from "../ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "../ui/dialog"; +import { Label } from "../ui/label"; +import { Textarea } from "../ui/textarea"; +import { + inspectionAlertApi, + type AlertRecord, + type AlertLevel, + type AlertRecordStatus, + type AlertStats, +} from "../../infrastructure/inspection/inspectionApi"; +import { toast } from "../ui/toast"; + +const LEVEL_COLORS: Record = { + critical: { + bg: "bg-red-500/10", + text: "text-red-500", + border: "border-red-500/30", + dot: "bg-red-500", + }, + warning: { + bg: "bg-orange-500/10", + text: "text-orange-500", + border: "border-orange-500/30", + dot: "bg-orange-500", + }, + info: { + bg: "bg-blue-500/10", + text: "text-blue-500", + border: "border-blue-500/30", + dot: "bg-blue-500", + }, +}; + +const LEVEL_LABELS: Record = { + critical: "严重", + warning: "警告", + info: "提示", +}; + +const STATUS_LABELS: Record = { + active: "待处理", + acknowledged: "已确认", + resolved: "已恢复", + closed: "已关闭", +}; + +const STATUS_COLORS: Record = { + active: "text-red-500", + acknowledged: "text-orange-500", + resolved: "text-green-500", + closed: "text-muted-foreground", +}; + +export function AlertCenterPage() { + const [alerts, setAlerts] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedAlert, setSelectedAlert] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [noteDialogOpen, setNoteDialogOpen] = useState(false); + const [noteText, setNoteText] = useState(""); + + // 筛选 + const [filterLevel, setFilterLevel] = useState("all"); + const [filterStatus, setFilterStatus] = useState("all"); + const [searchText, setSearchText] = useState(""); + const [activeTab, setActiveTab] = useState<"active" | "all">("active"); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [statsData, alertsData] = await Promise.all([ + inspectionAlertApi.getStats(), + activeTab === "active" + ? inspectionAlertApi.getActive() + : inspectionAlertApi.list({ limit: 200 }), + ]); + setStats(statsData); + setAlerts(alertsData); + } catch (e: any) { + console.error("[AlertCenter] 加载失败:", e); + } finally { + setLoading(false); + } + }, [activeTab]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 筛选后的数据 + const filteredAlerts = (alerts ?? []).filter((a) => { + if (filterLevel !== "all" && a.level !== filterLevel) return false; + if (filterStatus !== "all" && a.status !== filterStatus) return false; + if (searchText) { + const s = searchText.toLowerCase(); + if ( + !a.device_name.toLowerCase().includes(s) && + !a.rule_name.toLowerCase().includes(s) && + !a.message.toLowerCase().includes(s) + ) + return false; + } + return true; + }); + + const handleViewDetail = (alert: AlertRecord) => { + setSelectedAlert(alert); + setDetailOpen(true); + }; + + const handleAcknowledge = async () => { + if (!selectedAlert) return; + try { + await inspectionAlertApi.acknowledge(selectedAlert.id, "operator"); + toast.success("已确认告警"); + setDetailOpen(false); + loadData(); + } catch (e: any) { + toast.error("操作失败"); + } + }; + + const handleResolve = async () => { + if (!selectedAlert) return; + try { + await inspectionAlertApi.resolve(selectedAlert.id, "manual", "operator"); + toast.success("已恢复告警"); + setDetailOpen(false); + loadData(); + } catch (e: any) { + toast.error("操作失败"); + } + }; + + const handleClose = () => { + if (!selectedAlert) return; + setNoteText(""); + setNoteDialogOpen(true); + }; + + const handleCloseConfirm = async () => { + if (!selectedAlert) return; + try { + await inspectionAlertApi.close(selectedAlert.id, noteText); + toast.success("已关闭告警"); + setNoteDialogOpen(false); + setDetailOpen(false); + loadData(); + } catch (e: any) { + toast.error("操作失败"); + } + }; + + const formatTime = (ts: number) => { + return new Date(ts).toLocaleString(); + }; + + const getTimeAgo = (ts: number) => { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return "刚刚"; + if (mins < 60) return `${mins} 分钟前`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours} 小时前`; + const days = Math.floor(hours / 24); + return `${days} 天前`; + }; + + return ( +
+ {/* 顶部统计 */} +
+
+
+
+ +
+
+

告警中心

+

+ 监控设备巡检产生的告警 +

+
+
+ +
+ + {/* 统计卡片 */} +
+
+
+ + 严重告警 +
+
+ {stats?.active_critical ?? 0} +
+
+
+
+ + 警告 +
+
+ {stats?.active_warning ?? 0} +
+
+
+
+ + 提示 +
+
+ {stats?.active_info ?? 0} +
+
+
+
+ + 已恢复 +
+
+ {stats?.resolved ?? 0} +
+
+
+
+ + {/* 筛选栏 */} +
+
+ + +
+ +
+ + + + {activeTab === "all" && ( + + )} + + setSearchText(e.target.value)} + className="w-48" + /> +
+ + {/* 告警列表 */} +
+ {loading ? ( +
加载中...
+ ) : filteredAlerts.length === 0 ? ( +
+ +

暂无告警记录

+

+ {activeTab === "active" ? "所有设备运行正常" : "还没有产生过告警"} +

+
+ ) : ( +
+ {filteredAlerts.map((alert) => { + const colors = LEVEL_COLORS[alert.level]; + return ( +
handleViewDetail(alert)} + className="flex items-center gap-4 px-6 py-3 hover:bg-accent/30 cursor-pointer transition-colors" + > + {/* 级别指示点 */} +
+ + {/* 主要内容 */} +
+
+ {alert.device_name} + + {LEVEL_LABELS[alert.level]} + + + {STATUS_LABELS[alert.status]} + +
+
+ {alert.rule_name} — {alert.message} +
+
+ + {/* 时间 */} +
+
+ {getTimeAgo(alert.triggered_at)} +
+
+ {formatTime(alert.triggered_at)} +
+
+ + +
+ ); + })} +
+ )} +
+ + {/* 详情弹窗 */} + + + {selectedAlert && ( + <> + + +
+ 告警详情 + + + +
+
+
+
设备名称
+
{selectedAlert.device_name}
+
+
+
告警级别
+ + {LEVEL_LABELS[selectedAlert.level]} + +
+
+ +
+
规则名称
+
{selectedAlert.rule_name}
+
+ +
+
告警消息
+
+ {selectedAlert.message} +
+
+ +
+
+
当前值
+
{selectedAlert.metric_value}
+
+
+
阈值
+
{selectedAlert.threshold}
+
+
+ +
+
触发时间
+
{formatTime(selectedAlert.triggered_at)}
+
+ +
+
当前状态
+
+ {STATUS_LABELS[selectedAlert.status]} +
+
+ + {selectedAlert.note && ( +
+
备注
+
{selectedAlert.note}
+
+ )} +
+ + + +
+ {selectedAlert.status === "active" && ( + + )} + {(selectedAlert.status === "active" || + selectedAlert.status === "acknowledged") && ( + + )} + {selectedAlert.status !== "closed" && ( + + )} +
+
+ + )} + +
+ + {/* 关闭备注弹窗 */} + + + + 关闭告警 + +
+ +