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
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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" /> 复制
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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[] = [];
|
||||
let hosts: any[] = [];
|
||||
let extensions: Record<string, any> = {};
|
||||
|
||||
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 (_) {}
|
||||
|
||||
let keys: KeyAny[] = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_KEYS);
|
||||
if (raw) keys = await decryptKeys(JSON.parse(raw));
|
||||
} catch (_) {}
|
||||
|
||||
let extensions: Record<string, any> = {};
|
||||
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>
|
||||
|
||||
|
||||
@@ -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,24 +1654,46 @@ 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;
|
||||
|
||||
// 地图发光底层(比地图稍大,形成发光边缘)
|
||||
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: texture },
|
||||
uMap: { value: loadedTexture },
|
||||
uColor: { value: new THREE.Color('#38bdf8') },
|
||||
uFade: { value: 0.12 },
|
||||
},
|
||||
@@ -1669,12 +1722,13 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
const glowPlane = new THREE.Mesh(glowGeo, glowMat);
|
||||
glowPlane.rotation.x = -Math.PI / 2;
|
||||
glowPlane.position.y = -0.58;
|
||||
scene.add(glowPlane);
|
||||
_sceneForHot.add(glowPlane);
|
||||
glowPlaneRef.current = glowPlane;
|
||||
|
||||
const mapGeo = new THREE.PlaneGeometry(mapW, mapH);
|
||||
const mapMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uMap: { value: texture },
|
||||
uMap: { value: loadedTexture },
|
||||
uOpacity: { value: 0.85 },
|
||||
uFade: { value: 0.08 },
|
||||
},
|
||||
@@ -1703,20 +1757,58 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
const mapPlane = new THREE.Mesh(mapGeo, mapMat);
|
||||
mapPlane.rotation.x = -Math.PI / 2;
|
||||
mapPlane.position.y = -0.6;
|
||||
scene.add(mapPlane);
|
||||
_sceneForHot.add(mapPlane);
|
||||
mapPlaneRef.current = mapPlane;
|
||||
|
||||
// 地图加载完更新网格裁剪平面
|
||||
// 更新网格裁剪平面(按真实 aspect)
|
||||
const newOnMapPlanes = makeClipPlanes(mapW, mapH);
|
||||
;(gridOnMap.material as THREE.Material).clippingPlanes = newOnMapPlanes;
|
||||
if (gridOnMapRef.current) {
|
||||
(gridOnMapRef.current.material as THREE.Material).clippingPlanes = newOnMapPlanes;
|
||||
}
|
||||
const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
|
||||
;(gridOutside.material as THREE.Material).clippingPlanes = newOutsidePlanes;
|
||||
},
|
||||
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>
|
||||
|
||||
474
components/settings/tabs/SettingsMonitoringTab.tsx
Normal file
474
components/settings/tabs/SettingsMonitoringTab.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
526
components/vault/WorkbenchView.tsx
Normal file
526
components/vault/WorkbenchView.tsx
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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") || "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
20
index.html
20
index.html
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
// ---------- 告警记录 ----------
|
||||
|
||||
@@ -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
149
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user