feat: add monitoring settings tab, workbench view, and inspection alert improvements
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled

This commit is contained in:
2026-10-30 12:12:04 +08:00
committed by trae-solo-agent
parent 16dd04f746
commit 4a02370006
26 changed files with 2569 additions and 233 deletions

View File

@@ -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<string, string>;
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

View File

@@ -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<string, unknown> };
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,

View File

@@ -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<string, unknown> | 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);
};
}, []);

View File

@@ -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,

View File

@@ -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, []);

View File

@@ -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
<span className="text-xs text-muted-foreground ml-2">(默认 8848,修改后需重启远程访问生效)</span>
</div>
{remoteStatus.running && remoteStatus.token && (
{remoteStatus.running && remoteStatus.url && (
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground shrink-0" />
<code className="flex-1 px-2 py-1 rounded-md bg-muted text-xs text-foreground truncate select-all">
http://localhost:{remoteStatus.port}/dashboard?token={remoteStatus.token}
{remoteStatus.url}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl} className="shrink-0">
<Copy size={14} className="mr-1" /> 复制

View File

@@ -2,7 +2,7 @@
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
*/
import { AppWindow, FileType, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
import { AppWindow, FileType, Keyboard, Monitor, 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";
@@ -33,6 +33,7 @@ const LazySettingsAITab = lazy(() => import("./settings/tabs/SettingsAITab"));
const LazySettingsSystemTab = lazy(() => import("./settings/tabs/SettingsSystemTab"));
const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab"));
const LazySettingsPluginsTab = lazy(() => import("./settings/tabs/SettingsPluginsTab"));
const LazySettingsMonitoringTab = lazy(() => import("./settings/tabs/SettingsMonitoringTab"));
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -418,6 +419,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
<Sparkles size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.ai")}</span>
</TabsTrigger>
<TabsTrigger
value="monitoring"
className={settingsTabTriggerClassName}
>
<Monitor size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>监控大屏</span>
</TabsTrigger>
</TabsList>
</div>
@@ -536,6 +544,11 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
<SettingsAITabContainer />
</SettingsLazyTab>
)}
{mountedTabs.has("monitoring") && (
<SettingsLazyTab value="monitoring">
<LazySettingsMonitoringTab />
</SettingsLazyTab>
)}
{mountedTabs.has("plugins") && pluginRuntimeAvailable && (
<SettingsLazyTab value="plugins">
<LazySettingsPluginsTab />

View File

@@ -88,7 +88,7 @@ const STATUS_COLORS: Record<AlertRecordStatus, string> = {
closed: "text-muted-foreground",
};
export function AlertCenterPage() {
export function AlertCenterPage({ initialAlertId }: { initialAlertId?: string }) {
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
const [stats, setStats] = useState<AlertStats | null>(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 "";

View File

@@ -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<string, any> = {};
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<ReportSchedule>(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 }: {
<div className="text-xs text-muted-foreground">按周期自动巡检</div>
</div>
<Switch checked={local.enabled}
onCheckedChange={(v) => { 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; });
}} />
</div>
{/* 巡检周期 */}
@@ -1300,7 +1332,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
<div className="flex items-center gap-2">
<input type="number" min={1} max={72} step={1} className="w-20 rounded border border-border/60 bg-background px-2 py-1 text-sm"
value={local.defaultIntervalHours}
onChange={(e) => setLocal(p => ({ ...p, defaultIntervalHours: Number(e.target.value) || 1 }))} />
onChange={(e) => edit(p => ({ ...p, defaultIntervalHours: Number(e.target.value) || 1 }))} />
<span className="text-xs text-muted-foreground">小时</span>
</div>
</div>
@@ -1310,7 +1342,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
<div className="mb-1 text-sm font-medium">日报告触发时间</div>
<input type="time" className="w-full rounded border border-border/60 bg-background px-2 py-1 text-sm"
value={local.dailyReportTime}
onChange={(e) => setLocal(p => ({ ...p, dailyReportTime: e.target.value }))} />
onChange={(e) => edit(p => ({ ...p, dailyReportTime: e.target.value }))} />
</div>
{/* AI 汇总 */}
@@ -1320,7 +1352,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
<div className="text-xs text-muted-foreground">手动触发快照时生成 AI 四段式汇总</div>
</div>
<Switch checked={local.aiSummaryEnabled}
onCheckedChange={(v) => setLocal(p => ({ ...p, aiSummaryEnabled: v }))} />
onCheckedChange={(v) => edit(p => ({ ...p, aiSummaryEnabled: v }))} />
</div>
</div>

View File

@@ -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<THREE.Mesh | null>(null);
const glowPlaneRef = useRef<THREE.Mesh | null>(null);
const mapTextureRef = useRef<THREE.Texture | null>(null);
const gridOnMapRef = useRef<THREE.GridHelper | null>(null);
const gridOutsideRef = useRef<THREE.GridHelper | null>(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',
}}
/>
<div
@@ -2779,6 +3040,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
borderBottom: '1px solid #4A9BFF',
background: 'linear-gradient(180deg, rgba(58,123,213,0.22) 0%, rgba(58,123,213,0.06) 100%)',
boxShadow: '0 0 16px rgba(74,155,255,0.3), inset 0 0 12px rgba(74,155,255,0.15)',
position: 'relative',
top: '14px',
}}
>
<h1
@@ -2802,6 +3065,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
background: 'linear-gradient(to left, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
marginLeft: '12px',
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
position: 'relative',
top: '14px',
}}
/>
</div>

View File

@@ -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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-mono text-foreground/80">
{value}{unit || ""}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step ?? 1}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-primary"
/>
</div>
);
}
export function SettingsMonitoringTab() {
const api = getInspectionApi();
const fileInputRef = useRef<HTMLInputElement>(null);
const [entries, setEntries] = useState<MapBgEntry[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [camera, setCamera] = useState<CameraConfig>(() => 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 (
<SettingsTabContent value="monitoring">
<SectionHeader title="监控大屏" />
{!isAvailable && (
<SettingCard padded>
<div className="flex items-start gap-2 text-amber-500">
<AlertTriangle size={18} className="mt-0.5" />
<div className="text-sm text-muted-foreground">
底图上传仅在 Electron 本机可用。远程浏览器版本暂不支持。
</div>
</div>
</SettingCard>
)}
{isAvailable && (
<>
{/* 当前底图 + 紧凑上传 */}
<SettingCard padded>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Image size={14} className="text-muted-foreground" />
<span className="text-sm font-medium">底图</span>
<span className="text-xs text-muted-foreground">
({selected ? "自定义" : "内置 /ditu.png"})
</span>
</div>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept={ALLOWED_ACCEPT}
className="hidden"
onChange={(e) => void handleFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
<Upload size={12} className="mr-1" />
{uploading ? "上传中..." : "上传新底图"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReset}
disabled={!selected}
>
<RotateCcw size={12} className="mr-1" /> 恢复默认
</Button>
</div>
</div>
<div className="rounded-md border border-border bg-muted/30 overflow-hidden">
{previewUrl ? (
<div className="relative bg-black">
<img
src={previewUrl}
alt="current map bg"
className="w-full max-h-36 object-contain"
draggable={false}
/>
<div className="absolute bottom-1 right-2 rounded bg-black/60 px-2 py-0.5 text-[11px] text-white/90">
{selected}
</div>
</div>
) : (
<div className="flex h-20 items-center justify-center text-xs text-muted-foreground">
使用内置 /ditu.png 默认底图
</div>
)}
</div>
</SettingCard>
{/* 已上传底图列表 */}
<SettingCard padded>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">已上传底图</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{sortedEntries.length}
</span>
</div>
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
刷新
</Button>
</div>
<ScrollArea className="max-h-56 rounded-md border border-border">
{sortedEntries.length === 0 ? (
<div className="flex h-20 items-center justify-center text-xs text-muted-foreground">
尚未上传自定义底图
</div>
) : (
<ul className="divide-y divide-border">
{sortedEntries.map((entry) => {
const isActive = selected === entry.filename;
return (
<li
key={entry.filename}
className={cn(
"flex items-center gap-3 px-3 py-1.5 text-sm",
isActive && "bg-primary/5",
)}
>
<div className="flex-1 min-w-0">
<div
className={cn(
"truncate",
isActive ? "font-medium text-foreground" : "text-foreground/90",
)}
title={entry.filename}
>
{entry.filename}
</div>
<div className="text-[11px] text-muted-foreground">
{formatSize(entry.size)} · {formatDate(entry.mtimeMs)}
</div>
</div>
{isActive ? (
<span className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Check size={10} /> 当前
</span>
) : (
<Button
variant="outline"
size="sm"
onClick={() => void handleSelect(entry.filename)}
>
应用
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => void handleDelete(entry.filename)}
title="删除"
className="text-destructive hover:text-destructive"
>
<Trash2 size={12} />
</Button>
</li>
);
})}
</ul>
)}
</ScrollArea>
</SettingCard>
</>
)}
{/* 相机视角配置(任何环境都渲染 —— 不依赖 inspection API) */}
<SettingCard padded className="space-y-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Camera size={14} className="text-muted-foreground" />
<span className="text-sm font-medium">拓扑相机视角</span>
</div>
<Button variant="ghost" size="sm" onClick={handleResetCamera}>
<RotateCcw size={12} className="mr-1" /> 默认
</Button>
</div>
<div className="space-y-4">
<SliderRow
label={<span className="flex items-center gap-1.5"><Maximize2 size={12} className="text-muted-foreground" />倾斜角(俯视 → 侧视)</span>}
value={camera.elevation}
min={15}
max={85}
onChange={(v) => setCamera((c) => ({ ...c, elevation: v }))}
unit="°"
/>
<SliderRow
label={<span className="flex items-center gap-1.5"><ZoomIn size={12} className="text-muted-foreground" />相机距离(近 → 远)</span>}
value={camera.distance}
min={30}
max={220}
onChange={(v) => setCamera((c) => ({ ...c, distance: v }))}
/>
<SliderRow
label="方位角(相机水平旋转)"
value={camera.azimuth}
min={0}
max={359}
onChange={(v) => setCamera((c) => ({ ...c, azimuth: v }))}
unit="°"
/>
<SliderRow
label="视野角 FOV(广角 → 长焦)"
value={camera.fov}
min={25}
max={95}
onChange={(v) => setCamera((c) => ({ ...c, fov: v }))}
unit="°"
/>
</div>
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={camera.autoRotate}
onChange={(e) => setCamera((c) => ({ ...c, autoRotate: e.target.checked }))}
className="accent-primary"
/>
自动旋转
</label>
<div className="flex items-center gap-2">
<span>旋转速度</span>
<input
type="range"
min={0.05}
max={2}
step={0.05}
value={camera.autoRotateSpeed}
onChange={(e) => setCamera((c) => ({ ...c, autoRotateSpeed: Number(e.target.value) }))}
className="w-28 accent-primary"
/>
<span className="font-mono w-10">{camera.autoRotateSpeed.toFixed(2)}</span>
</div>
</div>
</SettingCard>
</SettingsTabContent>
);
}
export default SettingsMonitoringTab;

View File

@@ -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<string, any>;
@@ -111,10 +115,12 @@ function VaultConnectionLogsSection({
ConnectionLogsManager,
hosts,
onOpenLogView,
initialLogId,
}: {
ConnectionLogsManager: React.ComponentType<any>;
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 (
<ConnectionLogsManager
logs={connectionLogs}
@@ -137,6 +151,34 @@ function VaultConnectionLogsSection({
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
// ─── 设置权威源同步(远程浏览器) ───
// 1. HTML 注入脚本里的同步 XHR 已在 React 启动前把权威设置填到 localStorage
// 2. 这里注册写后钩子:每次 localStorageAdapter 写都 POST 回权威源
React.useEffect(() => {
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<string>) => {
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<HTMLDivElement>(null);
@@ -600,6 +653,30 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
<div
className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
>
{/* 工作台 */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "workbench" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "workbench" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("workbench")}
>
<BarChart3 size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "工作台"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">工作台</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
@@ -948,7 +1025,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
? "justify-center p-0"
: "justify-start gap-3",
)}
onClick={onOpenSettings}
onClick={() => {
if (IS_REMOTE) {
setShowRemoteSettings(true);
} else {
onOpenSettings();
}
}}
>
<Settings size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("common.settings")}
@@ -1383,6 +1466,27 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
}}
/>
{/* 工作台 */}
{currentSection === "workbench" && (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<WorkbenchView
hosts={hosts}
keys={keys}
proxies={[]}
snippetCount={snippets?.length ?? 0}
activePortForwardCount={0}
onNavigate={(section: string, opts?: any) => {
if (opts) pendingSelectionRef.current = opts;
setCurrentSection(section);
}}
onConnectHost={(hostId: string) => {
const h = hosts?.find((x: any) => x.id === hostId);
if (h) handleHostConnect(h);
}}
/>
</div>
)}
{currentSection === "snippets" && (
<LazyLoadBoundary name="Snippets" resetKey="snippets">
<Suspense fallback={<VaultSectionLoading />}>
@@ -1556,6 +1660,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
ConnectionLogsManager={LazyConnectionLogsManager}
hosts={hosts}
onOpenLogView={onOpenLogView}
initialLogId={pendingSelectionRef.current?.logId ?? undefined}
/>
</Suspense>
</LazyLoadBoundary>
@@ -1580,7 +1685,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<AlertCenterPage />
<AlertCenterPage initialAlertId={pendingSelectionRef.current?.alertId ?? undefined} />
</InspectionErrorBoundary>
</div>
</Suspense>
@@ -2028,6 +2133,25 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
onUpdateHosts([...hosts, host]);
}}
/>
{/* Remote Settings Dialog (远程访问设置面板) */}
{IS_REMOTE && (
<Dialog open={showRemoteSettings} onOpenChange={setShowRemoteSettings}>
<DialogContent className="max-w-5xl max-h-[92vh] overflow-auto p-0 gap-0">
<DialogHeader className="px-6 pt-5 pb-3 border-b border-border">
<DialogTitle>{t("common.settings")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">
<Suspense fallback={<div className="p-8 text-center text-muted-foreground">加载设置中...</div>}>
<SettingsPage
settings={remoteSettings as any}
appLock={{ enabled: false, locked: false } as any}
/>
</Suspense>
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@@ -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<string, { label: string; color: string }> = {
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<string, string> = {
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 (
<div className="group relative overflow-hidden rounded-xl border border-border/50 bg-gradient-to-br from-card to-card/80 p-3 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-md">
<div className="flex items-start gap-2.5">
<div className={cn("flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg shadow-sm ring-1 ring-inset ring-current/10 transition-transform group-hover:scale-110 group-hover:rotate-3", accent)}>
<Icon size={15} strokeWidth={2.2} />
</div>
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-baseline gap-1.5">
<div className="text-xl font-bold tabular-nums leading-none tracking-tight text-foreground">{value}</div>
{sub && <span className="text-[10px] font-medium text-muted-foreground">{sub}</span>}
</div>
<div className="mt-1 text-[11px] font-medium tracking-wide text-muted-foreground/75">{label}</div>
</div>
</div>
</div>
);
}
function SectionTitle({ title, action }: { title: string; action?: React.ReactNode }) {
return (
<div className="mb-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-primary/70" />
<h3 className="text-sm font-semibold tracking-wide text-foreground/90">{title}</h3>
</div>
{action}
</div>
);
}
/* =================== 通用 ECharts hook =================== */
function useChart(optionFn: () => echarts.EChartsOption | null, deps: React.DependencyList) {
const ref = useRef<HTMLDivElement>(null);
const chartRef = useRef<echarts.ECharts | null>(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<string, InspectResult>();
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 <div ref={ref} className="h-[170px] w-full" />;
}
/* =================== 设备类型分布圆环图 =================== */
function DeviceTypeDonutChart({ hosts, extensions, catalogs }:
{ hosts: WorkbenchHost[]; extensions?: Record<string, DeviceExtension>; catalogs?: DeviceCatalog[] }) {
const ref = useChart(() => {
// 从 DEVICE_CATEGORY_META 动态构建 counts,彻底消除硬编码
const counts: Record<string, number> = {};
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 <div ref={ref} className="h-[170px] w-full" />;
}
/* =================== 滚动行组件 =================== */
function AlertScrollRow({ alert, onClick }: { alert: AlertRecord; onClick?: () => void }) {
const levelStyles: Record<string, string> = { 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<string, string> = { critical: "严重", warning: "警告", info: "提示" };
return (
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[10px] font-semibold", levelStyles[alert.level] || levelStyles.warning)}>
{(levelLabel[alert.level] || "警告")[0]}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground/90">{alert.message || "未知告警"}</div>
<div className="truncate text-[10px] text-muted-foreground">{alert.device_name || alert.device_id || "-"} · {alert.metric || "—"} = {alert.metric_value ?? "?"}</div>
</div>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(alert.triggered_at)}</span>
</button>
);
}
function ReportScrollRow({ report, onClick }: { report: ReportSnapshot; onClick?: () => void }) {
const dateLabel = new Date(report.timestamp).toLocaleDateString();
return (
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-indigo-500/15 text-indigo-500"><Activity size={10} /></div>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground/90">巡检快照 · {dateLabel}</div>
<div className="truncate text-[10px] text-muted-foreground">{report.devices?.length ?? 0} 台设备 · {(report.duration_ms / 1000).toFixed(1)}s · {report.trigger}</div>
</div>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(report.timestamp)}</span>
</button>
);
}
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 (
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-sky-500/15 text-sky-500"><Activity size={10} /></div>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground/90">日巡检报告 {report.date || "-"}
<span className={cn("ml-1.5 rounded px-1 py-px text-[9px] font-medium", statusColor)}>{modeLabel}</span>
</div>
<div className="truncate text-[10px] text-muted-foreground">{report.summary ? `${report.summary.deviceCount ?? 0} 台 · ${(report.summary.failRate * 100).toFixed(1)}% 失败` : ""}</div>
</div>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(report.timestamp)}</span>
</button>
);
}
function QuickConnectRow({ host, onConnect }: { host: WorkbenchHost; onConnect?: (host: WorkbenchHost) => void }) {
const label = host.label || host.hostname || "未命名";
return (
<button type="button" onClick={() => onConnect?.(host)}
className="group flex w-full items-center gap-2 rounded-lg border border-border/50 bg-card/60 px-2.5 py-2 text-left text-xs transition-colors hover:border-primary/30 hover:bg-primary/5">
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-primary/10 text-primary"><Terminal size={11} /></div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground/90">{label}</div>
<div className="truncate text-[10px] text-muted-foreground">{host.hostname || host.group || "—"}</div>
</div>
<ChevronRight size={10} className="flex-shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
);
}
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 (
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/40">
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded", isError ? "bg-red-500/10 text-red-500" : "bg-emerald-500/10 text-emerald-500")}>
<Terminal size={10} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground/90">{label}</div>
<div className="truncate text-[10px] text-muted-foreground">{log.username ? `${log.username}@` : ""}{log.hostname || ""}</div>
</div>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(log.startTime || log.startedAt)}</span>
</button>
);
}
/* =================== 主组件 =================== */
export function WorkbenchView({ hosts, keys, proxies, snippetCount = 0, activePortForwardCount = 0, onNavigate, onConnectHost }: WorkbenchSectionProps) {
const inspectionApi = getInspectionApi();
const [results, setResults] = useState<InspectResult[]>([]);
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
const [reports, setReports] = useState<ReportSnapshot[]>([]);
const [dailyReports, setDailyReports] = useState<DailyReport[]>([]);
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState<number>(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<string, DeviceExtension>);
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<string, InspectResult>();
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 (
<div className="flex h-full min-h-0 flex-col gap-3.5 p-5">
{/* 顶部标题条 */}
<div className="flex flex-shrink-0 items-center justify-between">
<div>
<h1 className="text-xl font-bold tracking-tight text-foreground">工作台</h1>
<p className="mt-1 text-[11px] text-muted-foreground/80">
{loading ? "加载中..." : lastUpdate ? `最后更新 ${formatTimeAgo(lastUpdate)}` : "暂无巡检数据"}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void fetchData()} disabled={loading} className="h-8 border-border/60 bg-background/60 text-xs font-medium hover:bg-background">
{loading ? <Loader2 size={12} className="mr-1 animate-spin" /> : <Zap size={12} className="mr-1" />}刷新
</Button>
</div>
{/* ============ 8 KPI 卡 ============ */}
<div className="grid flex-shrink-0 grid-cols-2 gap-2.5 sm:grid-cols-4 lg:grid-cols-8">
<KpiCard icon={Server} label="主机总数" value={hosts.length} accent="bg-sky-500/10 text-sky-500" />
<KpiCard icon={KeyRound} label="密钥" value={keys.length} accent="bg-emerald-500/10 text-emerald-500" />
<KpiCard icon={GitBranch} label="代理" value={proxies.length} accent="bg-violet-500/10 text-violet-500" />
<KpiCard icon={FileCode} label="代码片段" value={snippetCount} accent="bg-amber-500/10 text-amber-500" />
<KpiCard icon={Network} label="端口转发" value={activePortForwardCount} accent="bg-cyan-500/10 text-cyan-500" />
<KpiCard icon={AlertTriangle} label="活跃告警" value={alerts.length}
accent={alerts.length ? "bg-red-500/10 text-red-500" : "bg-emerald-500/10 text-emerald-500"}
sub={alerts.length ? "需关注" : "全部正常"} />
<KpiCard icon={Activity} label="已巡检" value={results.length} accent="bg-blue-500/10 text-blue-500" />
<KpiCard icon={Server} label="在线主机" value={stats.online}
sub={hosts.length ? `${((stats.online / hosts.length) * 100).toFixed(0)}%` : "—"} accent="bg-green-500/10 text-green-500" />
</div>
{/* ============ 主体:外层 3 列 grid,左 2 列 + 右 1 列各自独立 ============ */}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 lg:grid-cols-3">
{/* ===== 左侧 2 列:flex-col 独立容器 ===== */}
<div className="flex min-h-0 flex-col gap-3 lg:col-span-2">
{/* 左侧 Row A:快速连接 + 最近会话 */}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="快速连接" action={<Button variant="ghost" size="sm" onClick={() => navigate("hosts")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{quickConnectHosts.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">还没有主机</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-1.5 pr-1">{quickConnectHosts.map((h) => <QuickConnectRow key={h.id} host={h} onConnect={handleQuickConnect} />)}</div></ScrollArea>}
</div>
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="最近会话" action={<Button variant="ghost" size="sm" onClick={() => navigate("logs")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{recentLogs.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无记录</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{recentLogs.map((log) => <RecentSessionRow key={log.id || log.sessionId} log={log} hosts={hosts} onClick={() => navigate("logs", { logId: log.id || log.sessionId })} />)}</div></ScrollArea>}
</div>
</div>
{/* 左侧 Row B:巡检快照 + 巡检日报 */}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="巡检快照" action={<Button variant="ghost" size="sm" onClick={() => navigate("reports")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{reportList.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无快照</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{reportList.map((r) => <ReportScrollRow key={r.id} report={r} onClick={() => handlePreviewSnapshot(r)} />)}</div></ScrollArea>}
</div>
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="巡检日报" action={<Button variant="ghost" size="sm" onClick={() => navigate("reports")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{dailyReportList.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无日报</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{dailyReportList.map((r) => <DailyReportScrollRow key={r.id} report={r} onClick={() => handlePreviewDaily(r)} />)}</div></ScrollArea>}
</div>
</div>
{/* 左侧 Row C:快捷操作 */}
<div className="flex-shrink-0 rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="快捷操作" />
<div className="mt-3 grid grid-cols-5 gap-3">
{[
{ 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 }) => (
<button key={section} type="button" onClick={() => navigate(section)}
className={`group flex h-12 items-center gap-2.5 rounded-lg border bg-gradient-to-br px-3 text-left transition-all hover:-translate-y-0.5 hover:shadow-sm ${accent}`}>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-background/40 ring-1 ring-inset ring-current/15">
<Icon size={15} strokeWidth={2.2} className="transition-transform group-hover:scale-110" />
</span>
<div className="min-w-0 leading-tight">
<div className="text-[12px] font-semibold truncate">{label}</div>
<div className="text-[10px] text-muted-foreground/70 truncate">{sub}</div>
</div>
</button>
))}
</div>
</div>
</div>
{/* ===== 右侧 1 列:flex-col 独立容器 ===== */}
<div className="flex min-h-0 flex-col gap-3">
{/* 右侧 A:设备类型分布 — 自然矮 */}
<div className="flex flex-shrink-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="设备类型分布" />
{hosts.length === 0 ? <div className="flex items-center justify-center text-xs text-muted-foreground">暂无主机数据</div>
: <div className="flex items-center"><DeviceTypeDonutChart hosts={hosts} extensions={extensions} catalogs={catalogs} /></div>}
</div>
{/* 右侧 B:近一周巡检状态 — 自然矮 */}
<div className="flex flex-shrink-0 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="近一周巡检状态" action={<Button variant="ghost" size="sm" onClick={() => navigate("inspection")} className="h-7 text-xs">巡检中心 <ArrowRight size={10} className="ml-1" /></Button>} />
{results.length === 0 ? <div className="flex items-center justify-center text-xs text-muted-foreground">暂无巡检数据</div>
: <div className="flex items-end"><WeeklyOnlineOfflineBarChart results={results} /></div>}
</div>
{/* 右侧 C:活跃告警 — flex-1 填满剩余空间 */}
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/50 bg-card p-4">
<SectionTitle title="活跃告警" action={<Button variant="ghost" size="sm" onClick={() => navigate("alerts")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
{alertList.length === 0 ? <div className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground"><Activity size={14} className="text-emerald-500/60" /> 全部正常</div>
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{alertList.map((a) => <AlertScrollRow key={a.id} alert={a} onClick={() => navigate("alerts", { alertId: a.id })} />)}</div></ScrollArea>}
</div>
</div>
</div>
</div>
);
}
export default WorkbenchView;

View File

@@ -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,
};

View File

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

View File

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

View File

@@ -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 用)
@@ -1195,6 +1199,63 @@ function createBridgeRegistrar(context) {
}
});
// ─── 设置同步(主窗口 ↔ 远程浏览器 共享权威源) ───
// 主窗口 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');
// ─── 远程 HTTP Server(单机版浏览器访问) ───
@@ -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) {

View File

@@ -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",

View File

@@ -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),

View File

@@ -15,12 +15,63 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
// ─── 获取本机局域网 IPv4(优先以太网,过滤虚拟网卡) ────
function getLanIp() {
try {
const nets = os.networkInterfaces();
const candidates = [];
// 虚拟/内部/隧道网卡关键词(命中即跳过)
const VIRTUAL = [
"loopback", "docker", "veth", "vmware", "virtualbox", "hyper-v", "vEthernet",
"vbox", "bridge", "tailscale", "zerotier", "wireguard", "tun", "tap",
"bluetooth", "bth", "wsl", "localhost", "回环", "虚拟", "蓝牙",
];
// WiFi 关键词(降权)
const WIFI = ["wlan", "wi-fi", "wifi", "wireless", "无线"];
// 以太网关键词(优先)
const ETHERNET = ["以太网", "ethernet", "eth", "enp", "ens", "eno", "bond", "team"];
for (const ifaceName of Object.keys(nets)) {
const lower = ifaceName.toLowerCase();
// 过滤虚拟网卡
if (VIRTUAL.some((k) => lower.includes(k.toLowerCase()))) continue;
for (const net of nets[ifaceName] || []) {
if (net.family !== "IPv4" || net.internal || !net.address) continue;
const addr = net.address;
// 只保留内网段 IP
const isPrivate =
/^192\.168\./.test(addr) ||
/^10\./.test(addr) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(addr);
if (!isPrivate) continue;
// 基础分:以太网 +100,WiFi +50,其他 +10
let score = 10;
if (ETHERNET.some((k) => lower.includes(k.toLowerCase()))) score = 100;
else if (WIFI.some((k) => lower.includes(k.toLowerCase()))) score = 50;
candidates.push({ addr, score, iface: ifaceName });
}
}
if (candidates.length === 0) return "127.0.0.1";
candidates.sort((a, b) => b.score - a.score);
console.log("[RemoteHTTP] getLanIp 候选:", candidates.map((c) => `${c.addr}(${c.iface},score=${c.score})`).join(", "));
return candidates[0].addr;
} catch {
return "127.0.0.1";
}
}
let _server = null;
let _port = 8848;
let _token = null;
let _storage = null; // { readStorage, writeStorage }
let _onSettingsChanged = null; // 远程写完设置后通知主窗口
let _distDir = null; // 运行时解析的 dist 绝对路径
const STORAGE_KEY = "netcatty_remote_access";
@@ -147,7 +198,8 @@ function loadModules() {
const alertRuleStore = require("./bridges/inspection/alertRuleStore.cjs");
const reportStore = require("./bridges/inspection/reportStore.cjs");
const dailyReportStore = require("./bridges/inspection/dailyReportStore.cjs");
return { inspectionStorage, deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore, reportStore, dailyReportStore };
const reportScheduleStore = require("./bridges/inspection/reportScheduleStore.cjs");
return { inspectionStorage, deviceStore, alertRecordStore, inspectionResultStore, alertRuleStore, reportStore, dailyReportStore, reportScheduleStore };
}
// 每次 API 请求前重置所有 store 的模块级缓存,强制从磁盘重新加载
@@ -255,6 +307,62 @@ function readStorage(key) {
// ─── 路由 ────
async function handleApi(pathname, method, body, query) {
// /api/settings 路由不依赖 modules,先快速处理
if (pathname === "/api/settings") {
console.log("[RemoteHTTP]", method, "/api/settings", "body:", typeof body === "string" ? body?.substring(0, 200) : JSON.stringify(body));
try {
if (method === "GET") {
const all = _storage?.readStorage?.("__settings__all__") || {};
let parsed = {};
try { parsed = typeof all === "string" ? JSON.parse(all) : (all || {}); } catch {}
return { ok: true, settings: parsed };
}
if (method === "POST") {
let parsedBody;
try { parsedBody = JSON.parse(body || "{}"); } catch (e) {
console.warn("[RemoteHTTP] POST /api/settings bad JSON:", body);
return { ok: false, error: "invalid JSON: " + e.message };
}
const entries = parsedBody.entries || parsedBody;
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
console.warn("[RemoteHTTP] POST /api/settings bad entries:", typeof entries);
return { ok: false, error: "body must be object of entries" };
}
let current = {};
try {
const raw = _storage?.readStorage?.("__settings__all__");
current = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : {};
} catch {}
const merged = { ...current, ...entries };
try { _storage?.writeStorage?.("__settings__all__", JSON.stringify(merged)); } catch (e) {
console.warn("[RemoteHTTP] POST /api/settings writeStorage error:", e.message);
}
try { _onSettingsChanged?.(); } catch {}
console.log("[RemoteHTTP] POST /api/settings OK, keys:", Object.keys(entries).length);
return { ok: true, merged };
}
if (method === "DELETE") {
let parsedBody;
try { parsedBody = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON" }; }
const keys = Array.isArray(parsedBody.keys) ? parsedBody.keys : [];
if (!keys.length) return { ok: false, error: "keys array required" };
let current = {};
try {
const raw = _storage?.readStorage?.("__settings__all__");
current = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : {};
} catch {}
for (const k of keys) delete current[k];
try { _storage?.writeStorage?.("__settings__all__", JSON.stringify(current)); } catch {}
try { _onSettingsChanged?.(); } catch {}
return { ok: true, remaining: current };
}
return { ok: false, error: "method not allowed" };
} catch (e) {
console.error("[RemoteHTTP] /api/settings error:", e);
return { ok: false, error: String(e?.message || e) };
}
}
const modules = loadModules();
// 每次请求前重置所有 store 缓存,确保读到磁盘最新数据
resetAllCaches(modules);
@@ -349,18 +457,56 @@ async function handleApi(pathname, method, body, query) {
return { ok: true, rules };
}
// POST /api/alert/:id/acknowledge —— 确认告警
const alertActionMatch = pathname.match(/^\/api\/alert\/([\w-]+)\/(acknowledge|resolve|close|note)$/);
if (method === "POST" && alertActionMatch) {
const alertId = alertActionMatch[1];
const action = alertActionMatch[2];
let payload;
try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
const writeFn = (k, v) => _storage?.writeStorage?.(k, v);
try {
let result;
if (action === "acknowledge") {
result = alertRecordStore.acknowledgeAlert(readStorage, writeFn, alertId, payload.operator || "remote");
} else if (action === "resolve") {
result = alertRecordStore.resolveAlert(readStorage, writeFn, alertId, payload.reason || "manual", payload.operator || "remote");
} else if (action === "close") {
result = alertRecordStore.closeAlert(readStorage, writeFn, alertId, payload.note || "");
} else if (action === "note") {
result = alertRecordStore.updateNote(readStorage, writeFn, alertId, payload.note || "");
}
return { ok: true, alert: result };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
// GET /api/inspection/results —— 所有设备最新巡检结果(完整结构,monitoring 页面用)
if (method === "GET" && pathname === "/api/inspection/results") {
const all = inspectionResultStore.getLatestAll(readStorage);
return { ok: true, results: Object.values(all) };
}
// GET /api/inspection/results/recent —— 最近巡检结果
// GET /api/inspection/results/recent —— 最近巡检结果(支持 query.limit,默认 500 覆盖 7 天历史)
if (method === "GET" && pathname === "/api/inspection/results/recent") {
const all = inspectionResultStore.getRecent(readStorage, 50);
const limit = Math.min(parseInt(query.limit, 10) || 500, 5000);
const all = inspectionResultStore.getRecent(readStorage, limit);
return { ok: true, results: all };
}
// GET /api/inspection/results/range —— 按时间范围查询巡检历史(工作台柱状图用)
// 查询参数: start=timestamp&end=timestamp
if (method === "GET" && pathname === "/api/inspection/results/range") {
const start = parseInt(query.start, 10);
const end = parseInt(query.end, 10);
if (!start || !end || start >= end) {
return { ok: false, error: "Invalid start/end" };
}
const results = inspectionResultStore.getByTimeRange(readStorage, start, end);
return { ok: true, results };
}
// GET /api/devices/enabled —— 已启用巡检的设备列表
if (method === "GET" && pathname === "/api/devices/enabled") {
const enabled = deviceStore.getEnabledDevices(readStorage);
@@ -613,9 +759,10 @@ async function handleApi(pathname, method, body, query) {
}
// GET /api/vault/:key —— Vault 子页面通用只读路由
// 支持 keys/proxies/port-forwards/snippets/notes/known-hosts/connection-logs/groups/identities
// 敏感数据(keys/identities)自动脱敏
// 支持 hosts/keys/proxies/port-forwards/snippets/notes/known-hosts/connection-logs/groups/identities
// 敏感数据(keys/identities/hosts)自动脱敏
const _VAULT_ROUTE_MAP = {
hosts: "_vault_hosts",
keys: "_vault_keys",
proxies: "_vault_proxies",
"port-forwards": "_vault_port_forwards",
@@ -652,8 +799,8 @@ async function handleApi(pathname, method, body, query) {
if (typeof raw === "string") {
try { data = JSON.parse(raw); } catch { data = raw; }
}
// keys 和 identities 需要脱敏
if (key === "keys" || key === "identities") {
// keys、identities 和 hosts 需要脱敏(hosts 里可能带 password)
if (key === "keys" || key === "identities" || key === "hosts") {
data = _stripSensitive(data);
}
return { ok: true, data: data ?? (Array.isArray(data) ? [] : {}) };
@@ -662,6 +809,117 @@ async function handleApi(pathname, method, body, query) {
}
}
// POST /api/alert-rules —— 批量保存告警规则(远程浏览器编辑用)
if (method === "POST" && pathname === "/api/alert-rules") {
let rules;
try { rules = JSON.parse(body || "[]"); } catch { return { ok: false, error: "invalid JSON body" }; }
if (!Array.isArray(rules)) return { ok: false, error: "rules must be an array" };
try {
alertRuleStore.saveRules(readStorage, (k, v) => _storage?.writeStorage?.(k, v), rules);
return { ok: true, count: rules.length };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
// POST /api/alert-rule —— 单条 create/update/delete/toggle
// body: { action: 'create'|'update'|'delete'|'toggle', ...fields }
if (method === "POST" && pathname === "/api/alert-rule") {
let payload;
try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
const write = (k, v) => _storage?.writeStorage?.(k, v);
try {
if (payload.action === "create") {
const rule = alertRuleStore.createRule(readStorage, write, payload.data || payload);
return { ok: true, rule };
} else if (payload.action === "update") {
const rule = alertRuleStore.updateRule(readStorage, write, payload.id, payload.updates || payload.data);
return { ok: true, rule };
} else if (payload.action === "delete") {
const ok = alertRuleStore.deleteRule(readStorage, write, payload.id);
return { ok: !!ok };
} else if (payload.action === "toggle") {
const rule = alertRuleStore.toggleRule(readStorage, write, payload.id, !!payload.enabled);
return { ok: true, rule };
}
return { ok: false, error: "unknown action" };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
// POST /api/report/schedule —— 保存报告调度配置
if (method === "POST" && pathname === "/api/report/schedule") {
let config;
try { config = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
try {
const { reportScheduleStore } = loadModules();
const merged = reportScheduleStore.saveSchedule((k, v) => _storage?.writeStorage?.(k, v), config);
return { ok: true, schedule: merged };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
// POST /api/report/scheduler-toggle —— 开启/关闭自动调度
if (method === "POST" && pathname === "/api/report/scheduler-toggle") {
let payload;
try { payload = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
const enabled = payload.enabled !== false;
try {
const { reportScheduleStore, reportScheduler } = loadModules();
const current = reportScheduleStore.getSchedule(readStorage);
const merged = reportScheduleStore.saveSchedule((k, v) => _storage?.writeStorage?.(k, v), { ...current, enabled });
// 尝试触发调度器启停(远程模式下调度器可能不存在,try/catch 兜底)
try {
if (enabled && reportScheduler?.start) reportScheduler.start();
else if (!enabled && reportScheduler?.stop) reportScheduler.stop();
} catch (_) {}
return { ok: true, running: enabled, schedule: merged };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
// GET /api/report/scheduler-status —— 调度器状态
if (method === "GET" && pathname === "/api/report/scheduler-status") {
try {
const { reportScheduleStore } = loadModules();
const schedule = reportScheduleStore.getSchedule(readStorage);
return { ok: true, running: !!schedule.enabled, schedule };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
// POST /api/inspection/sys-config —— 更新系统配置
if (method === "POST" && pathname === "/api/inspection/sys-config") {
let updates;
try { updates = JSON.parse(body || "{}"); } catch { return { ok: false, error: "invalid JSON body" }; }
try {
const { deviceStore } = loadModules();
deviceStore.updateSysConfig(readStorage, (k, v) => _storage?.writeStorage?.(k, v), updates);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
// POST /api/devices/:hostId/enabled —— 单台设备巡检开关
const enableMatch = pathname.match(/^\/api\/devices\/([\w-]+)\/enabled$/);
if (method === "POST" && enableMatch) {
const hostId = enableMatch[1];
let enabled = true;
try { enabled = JSON.parse(body || "{}").enabled !== false; } catch {}
try {
const { deviceStore } = loadModules();
deviceStore.upsertExtension(readStorage, (k, v) => _storage?.writeStorage?.(k, v), hostId, { inspection_enabled: enabled });
return { ok: true, hostId, enabled };
} catch (e) {
return { ok: false, error: e?.message || String(e) };
}
}
// GET /api/report-preview —— 远程浏览器动态生成 PDF 并返回流
// query: mode=snapshot|daily&id=<snapshotId|reportId>
if (method === "GET" && pathname === "/api/report-preview") {
@@ -694,11 +952,17 @@ async function handleApi(pathname, method, body, query) {
filePath = await reportExport.exportSnapshotPdf(app, snap, snap.ai_summary || undefined, null, systemName);
} else if (mode === "daily") {
dailyReportStore._resetForTest?.();
const report = dailyReportStore.getReport(readStorage, id);
const allReports = dailyReportStore.listDailyReports(readStorage, 500);
const report = (allReports || []).find((r) => r?.id === id);
if (!report) return { ok: false, error: "日报告不存在" };
reportStore._resetForTest?.();
const allSnapshots = reportStore.listSnapshots(readStorage);
filePath = await reportExport.exportDailyReportPdf(app, report, null, allSnapshots, systemName);
const allSnapshots = reportStore.listSnapshots(readStorage, 500);
const pad = (n) => n.toString().padStart(2, "0");
const daySnaps = (allSnapshots || []).filter((s) => {
const d = new Date(s.timestamp);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` === report.date;
});
filePath = await reportExport.exportDailyReportPdf(app, report, null, daySnaps, systemName);
} else {
return { ok: false, error: "未知 mode" };
}
@@ -887,6 +1151,7 @@ async function handleApi(pathname, method, body, query) {
// ─── 主入口 ────
async function start(options) {
_storage = options?.storage;
_onSettingsChanged = options?.onSettingsChanged;
// 读配置
let enabled = false;
@@ -1000,12 +1265,12 @@ async function start(options) {
return;
}
// 读 dist/index.html 并在 </head> 前注入 token(唯一一处,避免破坏其他标签)
// 读 dist/index.html 并在 </head> 前注入 token + 同步设置预加载
// 同步 XHR 确保 React SPA 启动前 localStorage 已有权威设置值
try {
const htmlPath = path.join(resolveDistDir(), "index.html");
let html = fs.readFileSync(htmlPath, "utf-8");
const inject = `<script>window.__REMOTE_TOKEN__=${JSON.stringify(_token)};window.__REMOTE_BASE__='/';</script>`;
// 只注入一处:</head> 前最稳妥(Vite 模板里 </head> 只有一处)
const inject = `<script>window.__REMOTE_TOKEN__=${JSON.stringify(_token)};window.__REMOTE_BASE__='/';(function(){try{var x=new XMLHttpRequest();x.open('GET','/api/settings?token='+encodeURIComponent(window.__REMOTE_TOKEN__),false);x.send();if(x.status===200){var j=JSON.parse(x.responseText);if(j.ok&&j.settings){for(var k in j.settings){if(j.settings[k]!==null&&j.settings[k]!==undefined){localStorage.setItem(k,String(j.settings[k]));}}}}}catch(e){}var dirtyKeys={};var _si=localStorage.setItem.bind(localStorage);var _ri=localStorage.removeItem.bind(localStorage);localStorage.setItem=function(k,v){var old=localStorage.getItem(k);_si(k,String(v));dirtyKeys[k]=Date.now();if(old!==String(v)){try{globalThis.__NETCATTY_SETTINGS_SYNC__&&globalThis.__NETCATTY_SETTINGS_SYNC__(k,String(v));}catch(e){}}};localStorage.removeItem=function(k){dirtyKeys[k]=Date.now();_ri(k);try{globalThis.__NETCATTY_SETTINGS_SYNC__&&globalThis.__NETCATTY_SETTINGS_SYNC__(k,null);}catch(e){}};(function poll(){try{var x=new XMLHttpRequest();x.open('GET','/api/settings?token='+encodeURIComponent(window.__REMOTE_TOKEN__),true);x.onload=function(){if(x.status===200){var j=JSON.parse(x.responseText);if(j.ok&&j.settings){var now=Date.now();for(var k in j.settings){var authVal=j.settings[k];var curVal=localStorage.getItem(k);if(authVal!==null&&authVal!==undefined){if(curVal!==String(authVal)){var dirty=dirtyKeys[k];if(!dirty||now-dirty>2000){_si(k,String(authVal));try{window.dispatchEvent(new StorageEvent('storage',{key:k,oldValue:curVal,newValue:String(authVal),url:location.href}));}catch(e){}}}}else{if(curVal!==null){var dirty=dirtyKeys[k];if(!dirty||now-dirty>2000){_ri(k);try{window.dispatchEvent(new StorageEvent('storage',{key:k,oldValue:curVal,newValue:null,url:location.href}));}catch(e){}}}}}}}};x.send();}catch(e){}setTimeout(poll,3000);})();})();</script>`;
html = html.replace("</head>", inject + "</head>");
sendHtml(res, 200, html);
} catch (e) {
@@ -1017,12 +1282,14 @@ async function start(options) {
return new Promise((resolve) => {
_server.listen(_port, "0.0.0.0", () => {
const url = `http://0.0.0.0:${_port}/?token=${_token}`;
const lanIp = getLanIp();
const url = `http://${lanIp}:${_port}/?token=${_token}`;
console.log("[RemoteHTTP] ✅ 远程 HTTP 服务已启动");
console.log("[RemoteHTTP] 监听端口:", _port);
console.log("[RemoteHTTP] 访问 URL:", url);
console.log("[RemoteHTTP] 本机 URL (localhost):", `http://localhost:${_port}/?token=${_token}`);
console.log("[RemoteHTTP] 局域网 URL:", url);
console.log("[RemoteHTTP] dist 目录:", resolveDistDir());
resolve({ started: true, port: _port, token: _token, url });
resolve({ started: true, port: _port, token: _token, url, lanIp });
});
_server.on("error", (err) => {
console.error("[RemoteHTTP] 启动失败:", err.message);
@@ -1044,10 +1311,13 @@ function stop() {
}
function getStatus() {
const lanIp = _server ? getLanIp() : null;
return {
running: !!_server,
port: _port,
token: _token,
lanIp,
url: lanIp && _token ? `http://${lanIp}:${_port}/?token=${_token}` : null,
};
}

View File

@@ -4,6 +4,26 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--
crypto.randomUUID() polyfill —— 远程 HTTP 用 http://192.168.x.x 访问时
不是 Secure Context,浏览器禁止调用。在任何 React 代码之前注入。
-->
<script>
(function () {
try {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID !== 'function') {
crypto.randomUUID = function () {
// RFC 4122 v4 uuid 纯 JS 实现
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0;
var v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
}
} catch (e) { /* ignore */ }
})();
</script>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' data: blob: ws: wss: https:; img-src 'self' data: https:;" />
<title>NetMesh SSH</title>

View File

@@ -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';

View File

@@ -138,31 +138,44 @@ const REMOTE_API_MAP: Record<string, { method: string; path: string; arrayField?
getLatestAll: { method: "GET", path: "/api/inspection/results", arrayField: "results" },
getLatestByDevice: { method: "GET", path: "/api/inspection/results", arrayField: "results" },
getRecentResults: { method: "GET", path: "/api/inspection/results/recent", arrayField: "results" },
getResultsByTimeRange: { method: "GET", path: "/api/inspection/results/range", arrayField: "results" },
getActiveAlerts: { method: "GET", path: "/api/alerts", arrayField: "alerts" },
listAlerts: { method: "GET", path: "/api/alerts", arrayField: "alerts" },
getAlertStats: { method: "GET", path: "/api/alert-stats" },
listRules: { method: "GET", path: "/api/alert-rules", arrayField: "rules" },
getRule: { method: "GET", path: "/api/alert-rules", arrayField: "rules" },
createRule: { method: "POST", path: "/api/alert-rule" },
updateRule: { method: "POST", path: "/api/alert-rule" },
deleteRule: { method: "POST", path: "/api/alert-rule" },
toggleRule: { method: "POST", path: "/api/alert-rule" },
getAllExtensions: { method: "GET", path: "/api/devices", arrayField: "devices" },
getEnabledDevices: { method: "GET", path: "/api/devices/enabled", arrayField: "devices" },
schedulerStatus: { method: "GET", path: "/api/inspection/scheduler-status" },
getCatalog: { method: "GET", path: "/api/inspection/catalog", arrayField: "catalog" },
getSysConfig: { method: "GET", path: "/api/inspection/sys-config" },
updateSysConfig: { method: "POST", path: "/api/inspection/sys-config" },
getLabels: { method: "GET", path: "/api/inspection/labels" },
listReportSnapshots: { method: "GET", path: "/api/report/snapshots", arrayField: "snapshots" },
isReportSnapshotRunning: { method: "GET", path: "/api/report/snapshot-status" },
getReportSchedule: { method: "GET", path: "/api/report/schedule" },
saveReportSchedule: { method: "POST", path: "/api/report/schedule" },
toggleReportScheduler: { method: "POST", path: "/api/report/scheduler-toggle" },
reportSchedulerStatus: { method: "GET", path: "/api/report/scheduler-status" },
listDailyReports: { method: "GET", path: "/api/daily-reports", arrayField: "reports" },
batchSetInspectionEnabled: { method: "POST", path: "/api/devices/batch-enable" },
runReportAiSummary: { method: "POST", path: "/api/run-report-ai-summary" },
generateDailyReport: { method: "POST", path: "/api/generate-daily-report" },
acknowledgeAlert: { method: "POST", path: "/api/alert/__ID__/acknowledge" },
resolveAlert: { method: "POST", path: "/api/alert/__ID__/resolve" },
closeAlert: { method: "POST", path: "/api/alert/__ID__/close" },
updateAlertNote: { method: "POST", path: "/api/alert/__ID__/note" },
};
// 某些 API 返回数组 —— 在 REMOTE_API_MAP 里用 arrayField 标记
// 找不到映射的 API 默认返回 null,让调用方自己兜底
const ARRAY_METHODS = new Set([
"listReportSnapshots", "listDailyReports", "listRules", "getLatestAll",
"getLatestByDevice", "getRecentResults", "getActiveAlerts", "listAlerts",
"getLatestByDevice", "getRecentResults", "getResultsByTimeRange", "getActiveAlerts", "listAlerts",
"getAllExtensions", "getEnabledDevices", "getCatalog",
]);
@@ -178,10 +191,19 @@ async function remoteFetch(name: string, ...args: any[]): Promise<any> {
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<any> {
} 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<any> {
// 适配不同返回结构
if (data && typeof data === "object") {
if (Array.isArray(data)) return data;
// 优先用 arrayField 约定的字段
// getAllExtensions:必须把 devices 数组转成 Record<host_id, ext>
if (name === "getAllExtensions" && Array.isArray(data.devices)) {
const map: Record<string, any> = {};
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<string, DeviceExtension>
const map: Record<string, any> = {};
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<InspectionResult[]> =>
invoke("getRecentResults", limit),
getByTimeRange: (startTime: number, endTime: number): Promise<InspectionResult[]> =>
invoke("getResultsByTimeRange", startTime, endTime),
};
// ---------- 告警记录 ----------

View File

@@ -9,6 +9,10 @@ const safeParse = <T>(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<string>();
let emitChangedKeysTimer: ReturnType<typeof setTimeout> | 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 {}
},
};

149
package-lock.json generated
View File

@@ -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",

View File

@@ -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",