feat: 巡检增强 / 拓扑大屏 / 远程 HTTP / portable 绿色打包
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
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立 Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心 与告警中心页增强。 拓扑大屏:MonitoringTopologyView 扩展分组/布局控制, useMonitoringData 重构数据流。 远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露 IPC,registerBridges 注册 netcatty:http:open 桥。 portable 绿色打包: - electron-builder.config.cjs 恢复原始白名单(61MB app.asar), 收集器自动追踪运行时依赖; - portableData.cjs 重写为 .portable 标记检测; - scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage 的 http://localhost:5173 origin 批量迁移为 app://netcatty; - scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
This commit is contained in:
@@ -108,6 +108,24 @@ export function AppSideEffects() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pluginViewTabs = usePluginViewTabs();
|
const pluginViewTabs = usePluginViewTabs();
|
||||||
|
|
||||||
|
// 启动时自动 sync AI providers 到主进程(主进程会持久化到 inspectionStorage)
|
||||||
|
// 解决远程浏览器 / 主进程重启后 providerConfigs 为空的问题
|
||||||
|
useEffect(() => {
|
||||||
|
const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
if (isRemote) return; // 远程浏览器没有 preload bridge
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (!nc?.aiSyncProviders) return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("netcatty_ai_providers_v1");
|
||||||
|
if (raw) {
|
||||||
|
const providers = JSON.parse(raw);
|
||||||
|
if (Array.isArray(providers) && providers.length > 0) {
|
||||||
|
nc.aiSyncProviders(providers).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
|
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
|
||||||
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
|
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
|
||||||
// Combined state for the AddToWorkspaceDialog. null = closed; mode
|
// Combined state for the AddToWorkspaceDialog. null = closed; mode
|
||||||
|
|||||||
@@ -52,22 +52,44 @@ export function useAppLockRuntime(
|
|||||||
'getAppLockRuntimeState' | 'onAppLockRuntimeStateChanged'
|
'getAppLockRuntimeState' | 'onAppLockRuntimeStateChanged'
|
||||||
> | null | undefined,
|
> | null | undefined,
|
||||||
) {
|
) {
|
||||||
const [runtimeState, setRuntimeState] = useState<RuntimeAppLockState>(DEFAULT_RUNTIME_APP_LOCK_STATE);
|
// 浏览器远程访问环境下(无 Electron bridge),直接 unlocked
|
||||||
|
const BROWSER_ONLY = typeof window !== 'undefined' && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
const [runtimeState, setRuntimeState] = useState<RuntimeAppLockState>(
|
||||||
|
BROWSER_ONLY
|
||||||
|
? { initialized: true, locked: false, reason: null, version: 0, lastLockedAt: null, lastUnlockedAt: null, lastActivityAt: null }
|
||||||
|
: DEFAULT_RUNTIME_APP_LOCK_STATE,
|
||||||
|
);
|
||||||
const refreshRuntimeState = useCallback(async () => {
|
const refreshRuntimeState = useCallback(async () => {
|
||||||
|
// 浏览器远程访问:直接返回 unlocked
|
||||||
|
if (BROWSER_ONLY) {
|
||||||
|
const unlocked: RuntimeAppLockState = {
|
||||||
|
initialized: true,
|
||||||
|
locked: false,
|
||||||
|
reason: null,
|
||||||
|
version: 0,
|
||||||
|
lastLockedAt: null,
|
||||||
|
lastUnlockedAt: null,
|
||||||
|
lastActivityAt: null,
|
||||||
|
};
|
||||||
|
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, unlocked));
|
||||||
|
return unlocked;
|
||||||
|
}
|
||||||
const nextState = await bridge?.getAppLockRuntimeState?.();
|
const nextState = await bridge?.getAppLockRuntimeState?.();
|
||||||
const normalized = normalizeRuntimeAppLockState(nextState);
|
const normalized = normalizeRuntimeAppLockState(nextState);
|
||||||
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
|
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
|
||||||
return normalized;
|
return normalized;
|
||||||
}, [bridge]);
|
}, [bridge, BROWSER_ONLY]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const unsubscribe = bridge?.onAppLockRuntimeStateChanged?.((nextState) => {
|
const unsubscribe = BROWSER_ONLY
|
||||||
if (cancelled) return;
|
? () => {}
|
||||||
const normalized = normalizeRuntimeAppLockState(nextState);
|
: (bridge?.onAppLockRuntimeStateChanged?.((nextState) => {
|
||||||
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
|
if (cancelled) return;
|
||||||
}) ?? (() => {});
|
const normalized = normalizeRuntimeAppLockState(nextState);
|
||||||
|
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
|
||||||
|
}) ?? (() => {}));
|
||||||
|
|
||||||
void refreshRuntimeState().catch(() => {
|
void refreshRuntimeState().catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -81,7 +103,7 @@ export function useAppLockRuntime(
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [bridge, refreshRuntimeState]);
|
}, [bridge, refreshRuntimeState, BROWSER_ONLY]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runtimeState,
|
runtimeState,
|
||||||
|
|||||||
@@ -146,6 +146,12 @@ export function useReportAutoScheduler() {
|
|||||||
|
|
||||||
/** mount 后延迟同步一次 hostConfigs 给主进程,让主进程 reportScheduler 能独立执行定时快照 */
|
/** mount 后延迟同步一次 hostConfigs 给主进程,让主进程 reportScheduler 能独立执行定时快照 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 远程浏览器环境:vault localStorage 加密数据读不到,无法解密 hosts,跳过定时巡检报告逻辑
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
if (IS_REMOTE) {
|
||||||
|
console.info("[ReportAutoScheduler] 远程访问环境,跳过巡检报告自动调度");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const syncOnce = async () => {
|
const syncOnce = async () => {
|
||||||
if (hostsSyncedRef.current) return;
|
if (hostsSyncedRef.current) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ import {
|
|||||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||||
STORAGE_KEY_PRODUCT_NAME,
|
STORAGE_KEY_PRODUCT_NAME,
|
||||||
|
STORAGE_KEY_MONITORING_DASHBOARD_TITLE,
|
||||||
|
STORAGE_KEY_REPORT_PRODUCT_NAME,
|
||||||
} from '../../infrastructure/config/storageKeys';
|
} from '../../infrastructure/config/storageKeys';
|
||||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -273,10 +275,67 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
|||||||
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||||
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
|
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 产品名称配置(用于标题栏、侧边栏 Logo 等)
|
// 产品名称配置(用于标题栏、侧边栏 Logo 等)
|
||||||
const [productName, setProductName] = useState<string>(() => {
|
const [productName, setProductName] = useState<string>(() => {
|
||||||
return localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || '云启智能运维平台';
|
return localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || '云启智能运维平台';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 远程浏览器:启动时从 /api/dashboard-title 拉取产品名系列配置,写入 localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
if (!IS_REMOTE) return;
|
||||||
|
const token = (window as any).__REMOTE_TOKEN__ || "";
|
||||||
|
fetch(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
let changed = false;
|
||||||
|
if (d?.productName) {
|
||||||
|
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, d.productName);
|
||||||
|
setProductName(d.productName);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (d?.dashboardTitle) {
|
||||||
|
localStorageAdapter.writeString(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, d.dashboardTitle);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (d?.reportProductName !== undefined) {
|
||||||
|
localStorageAdapter.writeString(STORAGE_KEY_REPORT_PRODUCT_NAME, d.reportProductName);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
try { window.dispatchEvent(new CustomEvent('productNameChanged')); } catch {}
|
||||||
|
try { window.dispatchEvent(new CustomEvent('monitoringTitleChanged')); } catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Electron 本地:启动时从主进程读取产品名系列配置 → 恢复到 Local Storage
|
||||||
|
// 解决绿色便携版首次启动 Local Storage 空时显示旧默认值的问题
|
||||||
|
useEffect(() => {
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
if (IS_REMOTE) return; // 远程浏览器有上面的 effect 处理
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (!nc?.inspection) return;
|
||||||
|
|
||||||
|
const localProductName = localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME);
|
||||||
|
const needsRestore = !localProductName; // Local Storage 空才需要恢复
|
||||||
|
|
||||||
|
if (needsRestore) {
|
||||||
|
nc.inspection.getProductName?.().then((v: string) => {
|
||||||
|
if (v && v.length > 0) {
|
||||||
|
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, v);
|
||||||
|
setProductName(v);
|
||||||
|
console.info('[useSettingsState] restored productName from main:', v);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
// dashboardTitle 和 reportProductName 在 SettingsApplicationTab 里单独恢复
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
|
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
|
||||||
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
|
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
|
||||||
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
|
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
|
||||||
@@ -1171,6 +1230,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
|||||||
// Persist product name
|
// Persist product name
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, productName);
|
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, productName);
|
||||||
|
// 同步到主进程 inspection-data.json,远程浏览器 API 可读
|
||||||
|
try {
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
nc?.inspection?.saveProductName?.(productName).catch(() => {});
|
||||||
|
} catch {}
|
||||||
if (persistMountedRef.current) {
|
if (persistMountedRef.current) {
|
||||||
notifySettingsChanged(STORAGE_KEY_PRODUCT_NAME, productName);
|
notifySettingsChanged(STORAGE_KEY_PRODUCT_NAME, productName);
|
||||||
// 更新窗口标题栏
|
// 更新窗口标题栏
|
||||||
|
|||||||
@@ -1364,6 +1364,80 @@ export const useVaultState = () => {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
|
// ─── 远程浏览器环境:从 HTTP API 获取所有 Vault 数据,跳过 Electron localStorage 加密路径
|
||||||
|
const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
if (isRemote) {
|
||||||
|
try {
|
||||||
|
const token = (window as any).__REMOTE_TOKEN__ || "";
|
||||||
|
const base = (p: string) => `${p}?token=${encodeURIComponent(token)}`;
|
||||||
|
const apiBase = `/api/vault`;
|
||||||
|
|
||||||
|
// 并行 fetch 所有 vault 数据
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
fetch(base("/api/devices")).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/keys`)).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/proxies`)).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/port-forwards`)).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/snippets`)).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/notes`)).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/known-hosts`)).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/connection-logs`)).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/groups`)).then(r => r.json()),
|
||||||
|
fetch(base(`${apiBase}/identities`)).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
const getOk = (idx: number, fallback: any = null) =>
|
||||||
|
results[idx]?.status === "fulfilled" && (results[idx]?.value as any)?.ok
|
||||||
|
? (results[idx]?.value as any).data ?? (results[idx]?.value as any).devices ?? fallback
|
||||||
|
: fallback;
|
||||||
|
|
||||||
|
// hosts
|
||||||
|
const devicesData = getOk(0, []);
|
||||||
|
if (Array.isArray(devicesData)) {
|
||||||
|
const remoteHosts: Host[] = devicesData.map((d: any) => {
|
||||||
|
const catalogId = d.catalog_id || d.type || "";
|
||||||
|
return {
|
||||||
|
id: d.host_id || crypto.randomUUID(),
|
||||||
|
label: d.label || d.hostname || d.host_id,
|
||||||
|
hostname: d.hostname || "",
|
||||||
|
port: 22, username: "", tags: [], os: "linux",
|
||||||
|
deviceType: /switch|firewall|router/i.test(catalogId) ? "network" : "general",
|
||||||
|
distro: catalogId, distroMode: "auto",
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
hostsRef.current = remoteHosts;
|
||||||
|
setHosts(remoteHosts);
|
||||||
|
}
|
||||||
|
// keys(已被 remoteHttpServer strip 敏感字段)
|
||||||
|
const keysData = getOk(1, []);
|
||||||
|
if (Array.isArray(keysData)) setKeys(keysData);
|
||||||
|
// proxies
|
||||||
|
const proxiesData = getOk(2, []);
|
||||||
|
if (Array.isArray(proxiesData)) setProxyProfiles(proxiesData);
|
||||||
|
// snippets
|
||||||
|
const snData = getOk(4, []);
|
||||||
|
if (Array.isArray(snData)) setSnippets(snData);
|
||||||
|
// notes(可能是数组或对象)
|
||||||
|
const notesData = getOk(5, null);
|
||||||
|
if (notesData) setNotes(Array.isArray(notesData) ? notesData : (notesData.notes || []));
|
||||||
|
// known hosts
|
||||||
|
const khData = getOk(6, []);
|
||||||
|
if (Array.isArray(khData)) setKnownHosts(khData);
|
||||||
|
// connection logs
|
||||||
|
const logData = getOk(7, []);
|
||||||
|
if (Array.isArray(logData)) setConnectionLogs(logData);
|
||||||
|
// identities
|
||||||
|
const idData = getOk(9, []);
|
||||||
|
if (Array.isArray(idData)) setIdentities(idData);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[useVaultState] 远程 vault fetch 失败:", e);
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsInitialized(true);
|
||||||
|
setVaultInitialized(true);
|
||||||
|
}
|
||||||
|
return; // 远程环境到此为止,跳过所有 Electron IPC 加载
|
||||||
|
}
|
||||||
|
|
||||||
await withVaultImportLock("vault", async () => {
|
await withVaultImportLock("vault", async () => {
|
||||||
recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS);
|
recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS);
|
||||||
});
|
});
|
||||||
@@ -1401,7 +1475,56 @@ export const useVaultState = () => {
|
|||||||
hostsWritePendingRef.current = writePromise;
|
hostsWritePendingRef.current = writePromise;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updateHosts(INITIAL_HOSTS);
|
// 本地 Local Storage 为空(可能是 origin 隔离导致的打包版环境)
|
||||||
|
// 尝试从主进程 inspectionStorage 的 _vault_hosts 恢复
|
||||||
|
let restored = false;
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (nc?.inspection?.getVaultHosts) {
|
||||||
|
try {
|
||||||
|
const vaultHosts = await nc.inspection.getVaultHosts();
|
||||||
|
if (Array.isArray(vaultHosts) && vaultHosts.length > 0) {
|
||||||
|
// 主进程 inspectionStorage 现在已存完整字段(含凭证),直接复制
|
||||||
|
const migrated: Host[] = vaultHosts.map((v: any) => ({
|
||||||
|
id: v.id,
|
||||||
|
label: v.label || "",
|
||||||
|
hostname: v.hostname || "",
|
||||||
|
port: v.port || 22,
|
||||||
|
username: v.username || "",
|
||||||
|
password: v.password || undefined,
|
||||||
|
identityFileId: v.identityFileId || undefined,
|
||||||
|
privateKey: v.privateKey || "",
|
||||||
|
passphrase: v.passphrase || "",
|
||||||
|
tags: v.tags || [],
|
||||||
|
os: v.os || "linux",
|
||||||
|
type: v.type || "",
|
||||||
|
distro: v.distro || "",
|
||||||
|
group: v.group || "",
|
||||||
|
}));
|
||||||
|
if (cancelled) return;
|
||||||
|
const sanitized = normalizeVaultOrder(
|
||||||
|
migrated.map((h) => sanitizeHost(h)),
|
||||||
|
);
|
||||||
|
hostsRef.current = sanitized;
|
||||||
|
setHosts(sanitized);
|
||||||
|
// 加密写回 localStorage,后续启动就正常了
|
||||||
|
const ver = ++hostsWriteVersion.current;
|
||||||
|
const encryptPromise = encryptHosts(sanitized);
|
||||||
|
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
|
||||||
|
const writePromise = encryptPromise.then(async (enc) => {
|
||||||
|
if (ver !== hostsWriteVersion.current) return;
|
||||||
|
return commitEncryptedHostsUnderVaultLock(ver, sanitized, enc);
|
||||||
|
});
|
||||||
|
hostsWritePendingRef.current = writePromise;
|
||||||
|
restored = true;
|
||||||
|
console.info("[useVaultState] 从 inspectionStorage 恢复 hosts:", sanitized.length, "台");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[useVaultState] 从 inspectionStorage 恢复失败:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!restored) {
|
||||||
|
updateHosts(INITIAL_HOSTS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||||
@@ -2133,6 +2256,23 @@ export const useVaultState = () => {
|
|||||||
[importData],
|
[importData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Electron 本地:把所有 vault 数据同步到 inspection-data.json(远程 HTTP API 读)
|
||||||
|
// 仅在 Electron 本地生效(远程浏览器不走 sync,直接 fetch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if ((window as any).__REMOTE_TOKEN__) return; // 远程浏览器不走
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (!nc?.inspection?.syncVaultData) return;
|
||||||
|
try { nc.inspection.syncVaultData("hosts", hosts).catch(() => {}); } catch {}
|
||||||
|
try { nc.inspection.syncVaultData("keys", keys).catch(() => {}); } catch {}
|
||||||
|
try { nc.inspection.syncVaultData("identities", identities).catch(() => {}); } catch {}
|
||||||
|
try { nc.inspection.syncVaultData("proxies", proxyProfiles).catch(() => {}); } catch {}
|
||||||
|
try { nc.inspection.syncVaultData("snippets", snippets).catch(() => {}); } catch {}
|
||||||
|
try { nc.inspection.syncVaultData("notes", notes).catch(() => {}); } catch {}
|
||||||
|
try { nc.inspection.syncVaultData("known_hosts", knownHosts).catch(() => {}); } catch {}
|
||||||
|
try { nc.inspection.syncVaultData("connection_logs", connectionLogs).catch(() => {}); } catch {}
|
||||||
|
}, [hosts, keys, identities, proxyProfiles, snippets, notes, knownHosts, connectionLogs]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isInitialized,
|
isInitialized,
|
||||||
hosts,
|
hosts,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { ArrowUpCircle, Check, Loader2, RefreshCcw } from "lucide-react";
|
import { ArrowUpCircle, Check, Copy, Globe, Loader2, RefreshCcw, Server } from "lucide-react";
|
||||||
import AppLogo from "./AppLogo";
|
import AppLogo from "./AppLogo";
|
||||||
import AppWordmark from "./AppWordmark";
|
import AppWordmark from "./AppWordmark";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Switch } from "./ui/switch";
|
||||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||||
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
|
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
|
||||||
import { useI18n } from "../application/i18n/I18nProvider";
|
import { useI18n } from "../application/i18n/I18nProvider";
|
||||||
@@ -10,6 +11,7 @@ import { SettingsAnchor, SettingsTabContent } from "./settings/settings-ui";
|
|||||||
import { toast } from "./ui/toast";
|
import { toast } from "./ui/toast";
|
||||||
import { useStoredString } from "../application/state/useStoredString";
|
import { useStoredString } from "../application/state/useStoredString";
|
||||||
import { STORAGE_KEY_MONITORING_DASHBOARD_TITLE, STORAGE_KEY_REPORT_PRODUCT_NAME } from "../infrastructure/config/storageKeys";
|
import { STORAGE_KEY_MONITORING_DASHBOARD_TITLE, STORAGE_KEY_REPORT_PRODUCT_NAME } from "../infrastructure/config/storageKeys";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
type AppInfo = {
|
type AppInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,6 +38,66 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
|||||||
const [monitoringTitle, setMonitoringTitle] = useStoredString<string>(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, '云启Net智能巡检监测系统');
|
const [monitoringTitle, setMonitoringTitle] = useStoredString<string>(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, '云启Net智能巡检监测系统');
|
||||||
const [reportProductName, setReportProductName] = useStoredString<string>(STORAGE_KEY_REPORT_PRODUCT_NAME, '');
|
const [reportProductName, setReportProductName] = useStoredString<string>(STORAGE_KEY_REPORT_PRODUCT_NAME, '');
|
||||||
|
|
||||||
|
// Electron 本地:启动时从主进程恢复 dashboardTitle / reportProductName
|
||||||
|
// 解决绿色便携版首次启动 Local Storage 空时显示旧默认值的问题
|
||||||
|
useEffect(() => {
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
if (IS_REMOTE) return;
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (!nc?.inspection) return;
|
||||||
|
|
||||||
|
// 恢复大屏标题(如果 Local Storage 空或用的是默认值)
|
||||||
|
nc.inspection.getDashboardTitle?.().then((v: string) => {
|
||||||
|
if (v && v.length > 0 && v !== '云启Net智能巡检监测系统') {
|
||||||
|
const local = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
|
||||||
|
if (!local || local === '云启Net智能巡检监测系统') {
|
||||||
|
setMonitoringTitle(v);
|
||||||
|
console.info('[Settings] restored monitoringTitle:', v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// 恢复报告产品名
|
||||||
|
nc.inspection.getReportProductName?.().then((v: string) => {
|
||||||
|
if (v && v.length > 0) {
|
||||||
|
const local = localStorage.getItem(STORAGE_KEY_REPORT_PRODUCT_NAME);
|
||||||
|
if (!local || local === '') {
|
||||||
|
setReportProductName(v);
|
||||||
|
console.info('[Settings] restored reportProductName:', v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 同步到主进程 inspection-data.json(远程浏览器 API 可读)
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (monitoringTitle) nc?.inspection?.saveDashboardTitle?.(monitoringTitle).catch(() => {});
|
||||||
|
nc?.inspection?.saveReportProductName?.(reportProductName || '').catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
}, [monitoringTitle, reportProductName]);
|
||||||
|
|
||||||
|
// 远程 HTTP Server 状态
|
||||||
|
const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null }>({
|
||||||
|
running: false, port: 8848, token: null,
|
||||||
|
});
|
||||||
|
const [remoteLoading, setRemoteLoading] = useState(false);
|
||||||
|
const [remotePortDraft, setRemotePortDraft] = useState("8848");
|
||||||
|
|
||||||
|
const loadRemoteStatus = async () => {
|
||||||
|
try {
|
||||||
|
const s = await (window as any).netcatty?.remoteHttp?.getStatus?.();
|
||||||
|
if (s) {
|
||||||
|
setRemoteStatus({ running: !!s.running, port: s.port || 8848, token: s.token || null });
|
||||||
|
setRemotePortDraft(String(s.port || 8848));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { void loadRemoteStatus(); }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -99,6 +161,44 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
|||||||
setTimeout(() => setLastCheckResult('none'), 3000);
|
setTimeout(() => setLastCheckResult('none'), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── 远程访问 handlers ───
|
||||||
|
const handleToggleRemote = async (checked: boolean) => {
|
||||||
|
setRemoteLoading(true);
|
||||||
|
try {
|
||||||
|
const port = parseInt(remotePortDraft, 10) || 8848;
|
||||||
|
const res = await (window as any).netcatty?.remoteHttp?.configure?.(checked, port);
|
||||||
|
if (res?.ok) {
|
||||||
|
setRemoteStatus({
|
||||||
|
running: !!checked,
|
||||||
|
port: res.port || port,
|
||||||
|
token: res.token || remoteStatus.token,
|
||||||
|
});
|
||||||
|
setRemotePortDraft(String(res.port || port));
|
||||||
|
if (checked) toast.success("远程访问已启动");
|
||||||
|
else toast.success("远程访问已停止");
|
||||||
|
} else {
|
||||||
|
toast.error(res?.error || "操作失败");
|
||||||
|
await loadRemoteStatus();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || "操作失败");
|
||||||
|
await loadRemoteStatus();
|
||||||
|
} finally {
|
||||||
|
setRemoteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = async () => {
|
||||||
|
if (!remoteStatus.token) { toast.error("请先启用远程访问"); return; }
|
||||||
|
const url = `http://localhost:${remoteStatus.port}/dashboard?token=${remoteStatus.token}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast.success("访问链接已复制");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsTabContent value="application">
|
<SettingsTabContent value="application">
|
||||||
<div className="flex flex-col lg:flex-row gap-10 lg:gap-14">
|
<div className="flex flex-col lg:flex-row gap-10 lg:gap-14">
|
||||||
@@ -168,6 +268,65 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
|||||||
onCommit={(v) => setReportProductName(v)}
|
onCommit={(v) => setReportProductName(v)}
|
||||||
/>
|
/>
|
||||||
</SettingsAnchor>
|
</SettingsAnchor>
|
||||||
|
|
||||||
|
{/* 远程访问(浏览器 HTTP Server) */}
|
||||||
|
<SettingsAnchor anchorId="application-remote-access" className="mt-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server size={16} className="text-muted-foreground" />
|
||||||
|
<label className="text-sm font-medium text-foreground">远程访问</label>
|
||||||
|
{remoteStatus.running ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
运行中
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-muted text-muted-foreground">
|
||||||
|
已停止
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={remoteStatus.running}
|
||||||
|
disabled={remoteLoading}
|
||||||
|
onCheckedChange={handleToggleRemote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground w-16 shrink-0">端口</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={remotePortDraft}
|
||||||
|
onChange={(e) => setRemotePortDraft(e.target.value)}
|
||||||
|
className="w-24 px-2 py-1 rounded-md border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">(默认 8848,修改后需重启远程访问生效)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{remoteStatus.running && remoteStatus.token && (
|
||||||
|
<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}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCopyUrl} className="shrink-0">
|
||||||
|
<Copy size={14} className="mr-1" /> 复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
启用后可在同一局域网内通过浏览器访问本机数据看板(设备列表、告警、巡检快照)。
|
||||||
|
连接时需带上上方 token,建议仅在可信内网环境使用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SettingsAnchor>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,56 @@ export function AlertCenterPage() {
|
|||||||
setRcaLoading(true);
|
setRcaLoading(true);
|
||||||
setRcaResult(null);
|
setRcaResult(null);
|
||||||
try {
|
try {
|
||||||
|
// 远程浏览器环境:fetch 主进程 HTTP API 做简化版 RCA(不 SSH,只用已有巡检数据+AI)
|
||||||
|
const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
if (isRemote) {
|
||||||
|
const token = (window as any).__REMOTE_TOKEN__ || "";
|
||||||
|
const resp = await fetch(`/api/run-alert-rca?token=${encodeURIComponent(token)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ alertId: alert.id }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
toast.error("AI 分析失败: " + (data.error || "未知错误"));
|
||||||
|
setRcaLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 解析简化版 RCA 结果
|
||||||
|
const parsed = data.rca;
|
||||||
|
if (parsed) {
|
||||||
|
toast.success("AI 分析完成(远程简化版)");
|
||||||
|
// 构建 RunRCAResult 兼容格式
|
||||||
|
const rcaPayload = {
|
||||||
|
__rca_json: true,
|
||||||
|
generated_at: Date.now(),
|
||||||
|
source: "ai_simplified",
|
||||||
|
summary: parsed.summary,
|
||||||
|
confidence: parsed.confidence,
|
||||||
|
rootCauses: parsed.rootCauses,
|
||||||
|
recommendations: parsed.recommendations,
|
||||||
|
};
|
||||||
|
setRcaResult({
|
||||||
|
success: true, source: "ai",
|
||||||
|
rca: { summary: parsed.summary || "", confidence: parsed.confidence ?? 0, rootCauses: parsed.rootCauses || [], recommendations: parsed.recommendations || [], evidence: [] },
|
||||||
|
durationMs: 0,
|
||||||
|
toolInvocations: [{ tool: "ai_simplified_rca", ok: true, durationMs: 0 }],
|
||||||
|
});
|
||||||
|
// 持久化到告警 note(主进程已经写了一份,前端再写一次保证一致)
|
||||||
|
try {
|
||||||
|
const existingNote = alert.note || "";
|
||||||
|
const finalNote = "[RCA_JSON]" + JSON.stringify(rcaPayload);
|
||||||
|
if (existingNote !== finalNote) {
|
||||||
|
// 主进程已经写了,这里刷新一下本地告警列表
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
} else {
|
||||||
|
toast.error("AI 返回结果格式异常");
|
||||||
|
}
|
||||||
|
setRcaLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Electron 本地:原有的 runRCA(完整 Phase 1/2 + SSH 诊断)
|
||||||
toast.info("AI 正在分析故障根因...(最多 3 分钟)");
|
toast.info("AI 正在分析故障根因...(最多 3 分钟)");
|
||||||
const result = await runRCA(alert);
|
const result = await runRCA(alert);
|
||||||
setRcaResult(result);
|
setRcaResult(result);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Play,
|
Play,
|
||||||
|
Pause,
|
||||||
Search,
|
Search,
|
||||||
Server,
|
Server,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
inspectionResultApi,
|
inspectionResultApi,
|
||||||
inspectionExecApi,
|
inspectionExecApi,
|
||||||
inspectionAlertApi,
|
inspectionAlertApi,
|
||||||
|
inspectionSchedulerApi,
|
||||||
type DeviceExtension,
|
type DeviceExtension,
|
||||||
type DeviceCatalog,
|
type DeviceCatalog,
|
||||||
type InspectionResult,
|
type InspectionResult,
|
||||||
@@ -72,6 +74,7 @@ interface DeviceRow {
|
|||||||
|
|
||||||
export function InspectionCenterPage() {
|
export function InspectionCenterPage() {
|
||||||
const { hosts, keys } = useVaultState();
|
const { hosts, keys } = useVaultState();
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
|
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
|
||||||
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
||||||
const [latestResults, setLatestResults] = useState<Record<string, InspectionResult>>({});
|
const [latestResults, setLatestResults] = useState<Record<string, InspectionResult>>({});
|
||||||
@@ -79,6 +82,10 @@ export function InspectionCenterPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// 批量启用/停止巡检状态
|
||||||
|
const [batchEnabling, setBatchEnabling] = useState(false);
|
||||||
|
const [batchStopping, setBatchStopping] = useState(false);
|
||||||
|
|
||||||
// 筛选
|
// 筛选
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [filterCatalog, setFilterCatalog] = useState<string>("all");
|
const [filterCatalog, setFilterCatalog] = useState<string>("all");
|
||||||
@@ -128,6 +135,33 @@ export function InspectionCenterPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 批量启用/停止(必须在 loadData 之后声明,避免 TDZ)
|
||||||
|
const handleBatchEnable = useCallback(async () => {
|
||||||
|
setBatchEnabling(true);
|
||||||
|
try {
|
||||||
|
const res = await inspectionDeviceApi.batchSetEnabled(true);
|
||||||
|
toast.success(`已批量启用 ${res.count} 台设备自动巡检`);
|
||||||
|
await loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || "操作失败");
|
||||||
|
} finally {
|
||||||
|
setBatchEnabling(false);
|
||||||
|
}
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleBatchStop = useCallback(async () => {
|
||||||
|
setBatchStopping(true);
|
||||||
|
try {
|
||||||
|
const res = await inspectionDeviceApi.batchSetEnabled(false);
|
||||||
|
toast.success(`已批量停止 ${res.count} 台设备自动巡检`);
|
||||||
|
await loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || "操作失败");
|
||||||
|
} finally {
|
||||||
|
setBatchStopping(false);
|
||||||
|
}
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
@@ -164,11 +198,33 @@ export function InspectionCenterPage() {
|
|||||||
};
|
};
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 远程浏览器模式下,用定时轮询替代主进程事件推送(每 10 秒自动刷新)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_REMOTE) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void loadData(true);
|
||||||
|
}, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [IS_REMOTE, loadData]);
|
||||||
|
|
||||||
// 构建设备行数据
|
// 构建设备行数据
|
||||||
const deviceRows = useMemo<DeviceRow[]>(() => {
|
const deviceRows = useMemo<DeviceRow[]>(() => {
|
||||||
return (hosts || [])
|
// 远程浏览器:vault hosts 不可用,从 inspection extensions + 巡检结果合成虚拟 hosts
|
||||||
.filter((h) => h && h.id)
|
const effectiveHosts = (hosts && hosts.length > 0)
|
||||||
.map((host) => {
|
? hosts
|
||||||
|
: IS_REMOTE
|
||||||
|
? Object.values(extensions).map((ext: any) => ({
|
||||||
|
id: ext.host_id,
|
||||||
|
label: ext.label || ext.hostname || ext.host_id,
|
||||||
|
hostname: ext.hostname || "",
|
||||||
|
address: ext.hostname || "",
|
||||||
|
platform: "ssh",
|
||||||
|
}))
|
||||||
|
: (hosts || []);
|
||||||
|
|
||||||
|
return effectiveHosts
|
||||||
|
.filter((h: any) => h && h.id)
|
||||||
|
.map((host: any) => {
|
||||||
const ext = extensions[host.id];
|
const ext = extensions[host.id];
|
||||||
const catalog = catalogs.find((c) => c.id === ext?.catalog_id);
|
const catalog = catalogs.find((c) => c.id === ext?.catalog_id);
|
||||||
const lastResult = latestResults[host.id];
|
const lastResult = latestResults[host.id];
|
||||||
@@ -390,10 +446,12 @@ export function InspectionCenterPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={loadData} disabled={refreshing}>
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw size={14} className={`mr-2 ${refreshing ? "animate-spin" : ""}`} />
|
<Button variant="outline" onClick={loadData} disabled={refreshing}>
|
||||||
刷新
|
<RefreshCw size={14} className={`mr-2 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
@@ -499,7 +557,32 @@ export function InspectionCenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 设备列表 */}
|
{/* 设备列表 */}
|
||||||
<div className="flex-1 overflow-auto px-6 py-4">
|
<div className="flex items-center justify-between px-6 pt-3 pb-2">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
共 <span className="font-medium text-foreground">{filteredRows.length}</span> 台设备
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBatchEnable}
|
||||||
|
disabled={batchEnabling || loading}
|
||||||
|
>
|
||||||
|
{batchEnabling ? <RefreshCw size={12} className="mr-1 animate-spin" /> : <Play size={12} className="mr-1" />}
|
||||||
|
全部启用巡检
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBatchStop}
|
||||||
|
disabled={batchStopping || loading}
|
||||||
|
>
|
||||||
|
{batchStopping ? <RefreshCw size={12} className="mr-1 animate-spin" /> : <Pause size={12} className="mr-1" />}
|
||||||
|
全部停止巡检
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto px-6 pb-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
||||||
) : filteredRows.length === 0 ? (
|
) : filteredRows.length === 0 ? (
|
||||||
|
|||||||
@@ -410,12 +410,17 @@ export function ReportPage() {
|
|||||||
catch { toast.error("清空失败"); }
|
catch { toast.error("清空失败"); }
|
||||||
}, [loadSnapshots]);
|
}, [loadSnapshots]);
|
||||||
|
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
|
||||||
const handlePreviewSnapshotPdf = useCallback(async (snapshotId: string) => {
|
const handlePreviewSnapshotPdf = useCallback(async (snapshotId: string) => {
|
||||||
|
if (IS_REMOTE) {
|
||||||
|
const token = (window as any).__REMOTE_TOKEN__ || "";
|
||||||
|
window.open(`/api/report-preview?mode=snapshot&id=${encodeURIComponent(snapshotId)}&token=${encodeURIComponent(token)}`, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 内嵌预览(BrowserWindow + Electron PDF viewer),不存到 reports 目录
|
|
||||||
const result = await inspectionReportApi.previewSnapshotPdf(snapshotId);
|
const result = await inspectionReportApi.previewSnapshotPdf(snapshotId);
|
||||||
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
|
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
|
||||||
// 成功的话主进程已经弹出 BrowserWindow,不用再处理
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error("预览失败:" + (e?.message || String(e)));
|
toast.error("预览失败:" + (e?.message || String(e)));
|
||||||
}
|
}
|
||||||
@@ -452,8 +457,12 @@ export function ReportPage() {
|
|||||||
}, [snapshots]);
|
}, [snapshots]);
|
||||||
|
|
||||||
const handlePreviewDailyPdf = useCallback(async (reportId: string) => {
|
const handlePreviewDailyPdf = useCallback(async (reportId: string) => {
|
||||||
|
if (IS_REMOTE) {
|
||||||
|
const token = (window as any).__REMOTE_TOKEN__ || "";
|
||||||
|
window.open(`/api/report-preview?mode=daily&id=${encodeURIComponent(reportId)}&token=${encodeURIComponent(token)}`, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 内嵌预览(BrowserWindow + Electron PDF viewer),不存盘到 reports 目录
|
|
||||||
const result = await inspectionDailyReportApi.previewPdf(reportId);
|
const result = await inspectionDailyReportApi.previewPdf(reportId);
|
||||||
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
|
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* 运行监控 - 3D 拓扑大屏
|
* 运行监控 - 3D 拓扑大屏
|
||||||
*
|
*
|
||||||
* 移植自 ITYUNWEI TopologyView.vue
|
* 移植自 ITYUNWEI TopologyView.vue
|
||||||
@@ -328,7 +328,12 @@ function formatRelativeTime(timestamp: number): string {
|
|||||||
|
|
||||||
// 扩展 Host 的设备类型信息
|
// 扩展 Host 的设备类型信息
|
||||||
function classifyHostType(host: Host): string {
|
function classifyHostType(host: Host): string {
|
||||||
const distro = (host.distro || '').toLowerCase();
|
// 优先用直接的 type 字段(remote API 会返回 switch/server/firewall/router)
|
||||||
|
const directType = (host as any).type;
|
||||||
|
if (directType && ['server', 'switch', 'router', 'firewall', 'storage', 'workstation'].includes(directType)) {
|
||||||
|
return directType;
|
||||||
|
}
|
||||||
|
const distro = (host.distro || (host as any).catalog || (host as any).catalog_id || '').toLowerCase();
|
||||||
const name = (host.name || '').toLowerCase();
|
const name = (host.name || '').toLowerCase();
|
||||||
const label = (host.label || '').toLowerCase();
|
const label = (host.label || '').toLowerCase();
|
||||||
const combined = `${distro} ${name} ${label}`;
|
const combined = `${distro} ${name} ${label}`;
|
||||||
@@ -476,24 +481,94 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [legend, setLegend] = useState(true);
|
const [legend, setLegend] = useState(true);
|
||||||
const [showConnectionLegend, setShowConnectionLegend] = useState(true);
|
const [showConnectionLegend, setShowConnectionLegend] = useState(true);
|
||||||
const [monitoringTitle, setMonitoringTitle] = useState<string>(() =>
|
const [monitoringTitle, setMonitoringTitle] = useState<string>(() => {
|
||||||
localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE) || '云启Net智能巡检监测系统'
|
// 先给默认值,useEffect 里远程异步加载(时序更安全)
|
||||||
);
|
if (typeof window !== "undefined") {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
|
||||||
|
if (saved) return saved;
|
||||||
|
}
|
||||||
|
return '云启Net智能巡检监测系统';
|
||||||
|
});
|
||||||
|
|
||||||
// 监听 localStorage 中大屏标题变化(设置页面修改后实时生效)
|
// 远程环境:异步加载标题 / 位置 / 连接线(useState init 阶段 token 可能还没注入)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const token = (window as any).__REMOTE_TOKEN__;
|
||||||
|
if (!token) return; // 不是远程环境或 token 未就绪
|
||||||
|
|
||||||
|
const fetchJSON = (url: string): Promise<any> =>
|
||||||
|
fetch(url).then(r => r.json()).catch(() => null);
|
||||||
|
|
||||||
|
// 1. 标题
|
||||||
|
fetchJSON(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
|
||||||
|
.then(json => { if (json?.ok && json.title) setMonitoringTitle(json.title); });
|
||||||
|
|
||||||
|
// 2. 拓扑位置
|
||||||
|
fetchJSON(`/api/topology-positions?token=${encodeURIComponent(token)}`)
|
||||||
|
.then(json => {
|
||||||
|
if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
|
||||||
|
setSavedPositions(json.positions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 连接线
|
||||||
|
fetchJSON(`/api/topology-links?token=${encodeURIComponent(token)}`)
|
||||||
|
.then(json => { if (json?.ok && Array.isArray(json.links)) setCustomLinks(json.links); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Electron 环境:监听 localStorage 变化 + mount 时同步 hosts/title 给主进程
|
||||||
|
useEffect(() => {
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
console.log('[MonitoringTopologyView sync] nc exists:', !!nc, 'nc.inspection exists:', !!nc?.inspection, 'hosts.length:', hosts?.length);
|
||||||
|
if (!nc?.inspection) {
|
||||||
|
console.log('[MonitoringTopologyView sync] skip — not Electron env');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mount 时一次性同步 vault hosts + 标题 + 拓扑位置 + 拓扑连线
|
||||||
|
if (hosts?.length > 0) {
|
||||||
|
console.log('[MonitoringTopologyView sync] saving vault hosts:', hosts.length);
|
||||||
|
nc.inspection.saveVaultHosts(hosts).then((r: any) => console.log('saveVaultHosts result:', r)).catch((e: any) => console.warn('saveVaultHosts error:', e));
|
||||||
|
}
|
||||||
|
const savedTitle = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
|
||||||
|
console.log('[MonitoringTopologyView sync] savedTitle:', savedTitle);
|
||||||
|
if (savedTitle) nc.inspection.saveDashboardTitle(savedTitle).catch(() => {});
|
||||||
|
const savedPos = localStorage.getItem('monitoring-topology-positions');
|
||||||
|
if (savedPos) nc.inspection.saveTopologyPositions(JSON.parse(savedPos)).catch(() => {});
|
||||||
|
const savedLinks = localStorage.getItem('monitoring-topology-links');
|
||||||
|
console.log('[MonitoringTopologyView sync] savedLinks exists:', !!savedLinks);
|
||||||
|
if (savedLinks) {
|
||||||
|
try { nc.inspection.saveTopologyLinks(JSON.parse(savedLinks)).catch(() => {}); } catch {}
|
||||||
|
}
|
||||||
|
if (!savedLinks && nc.inspection.getTopologyLinks) {
|
||||||
|
nc.inspection.getTopologyLinks().then((linksFromMain: any) => {
|
||||||
|
if (Array.isArray(linksFromMain) && linksFromMain.length > 0) {
|
||||||
|
console.info('[MonitoringTopologyView] restore links from main:', linksFromMain.length);
|
||||||
|
localStorage.setItem('monitoring-topology-links', JSON.stringify(linksFromMain));
|
||||||
|
try { setCustomLinks(linksFromMain); } catch {}
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncToMain = (value: string) => {
|
||||||
|
if (value) nc.inspection.saveDashboardTitle(value).catch(() => {});
|
||||||
|
};
|
||||||
const handler = (e: StorageEvent) => {
|
const handler = (e: StorageEvent) => {
|
||||||
if (e.key === STORAGE_KEY_MONITORING_DASHBOARD_TITLE && e.newValue !== null) {
|
if (e.key === STORAGE_KEY_MONITORING_DASHBOARD_TITLE && e.newValue !== null) {
|
||||||
setMonitoringTitle(e.newValue || '云启Net智能巡检监测系统');
|
setMonitoringTitle(e.newValue || '云启Net智能巡检监测系统');
|
||||||
|
syncToMain(e.newValue);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 同页 localStorage 变化不走 storage 事件,直接在同页用 CustomEvent 广播
|
|
||||||
const localHandler = () => {
|
const localHandler = () => {
|
||||||
const v = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
|
const v = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
|
||||||
setMonitoringTitle(v || '云启Net智能巡检监测系统');
|
setMonitoringTitle(v || '云启Net智能巡检监测系统');
|
||||||
|
syncToMain(v || '');
|
||||||
};
|
};
|
||||||
window.addEventListener('storage', handler);
|
window.addEventListener('storage', handler);
|
||||||
window.addEventListener('monitoringTitleChanged' as any, localHandler);
|
window.addEventListener('monitoringTitleChanged' as any, localHandler);
|
||||||
|
// mount 时同步一次当前标题
|
||||||
|
const initVal = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
|
||||||
|
if (initVal) syncToMain(initVal);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('storage', handler);
|
window.removeEventListener('storage', handler);
|
||||||
window.removeEventListener('monitoringTitleChanged' as any, localHandler);
|
window.removeEventListener('monitoringTitleChanged' as any, localHandler);
|
||||||
@@ -843,14 +918,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
});
|
});
|
||||||
const [pendingLink, setPendingLink] = useState<{ sourceId: string; targetId: string } | null>(null);
|
const [pendingLink, setPendingLink] = useState<{ sourceId: string; targetId: string } | null>(null);
|
||||||
|
|
||||||
// 用户创建的连线(从 localStorage 加载)
|
// 用户创建的连线 —— 默认空数组,远程/本地各自从 useEffect 加载
|
||||||
const [customLinks, setCustomLinks] = useState<Link[]>(() => {
|
const [customLinks, setCustomLinks] = useState<Link[]>(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('monitoring-topology-links');
|
const saved = localStorage.getItem('monitoring-topology-links');
|
||||||
return saved ? JSON.parse(saved) : [];
|
return saved ? JSON.parse(saved) : [];
|
||||||
} catch {
|
} catch { return []; }
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 连线模式使用 ref 避免闭包问题
|
// 连线模式使用 ref 避免闭包问题
|
||||||
@@ -884,6 +957,13 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
localStorage.setItem('monitoring-topology-links', JSON.stringify(customLinks));
|
localStorage.setItem('monitoring-topology-links', JSON.stringify(customLinks));
|
||||||
}, [customLinks]);
|
}, [customLinks]);
|
||||||
|
|
||||||
|
// 连线变化时同步到主进程 inspection-data.json(供远程浏览器 API 读取)
|
||||||
|
useEffect(() => {
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (!nc?.inspection) return;
|
||||||
|
nc.inspection.saveTopologyLinks(customLinks).catch(() => {});
|
||||||
|
}, [customLinks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('monitoring-topology-excluded-links', JSON.stringify(excludedAutoLinks));
|
localStorage.setItem('monitoring-topology-excluded-links', JSON.stringify(excludedAutoLinks));
|
||||||
}, [excludedAutoLinks]);
|
}, [excludedAutoLinks]);
|
||||||
@@ -902,10 +982,16 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
exitFullscreen();
|
exitFullscreen();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// 浏览器原生全屏变化(ESC 退出),同步 React 状态
|
||||||
|
const onFsChange = () => {
|
||||||
|
if (!document.fullscreenElement && isFullscreen) {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
document.addEventListener('keydown', onKeyDown);
|
document.addEventListener('keydown', onKeyDown);
|
||||||
// body 加 class,配合 CSS 让 monitoring-page 固定全屏
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
document.body.classList.add('monitoring-fullscreen-active');
|
document.body.classList.add('monitoring-fullscreen-active');
|
||||||
// Electron 环境下同时让窗口也全屏
|
// Electron 环境下同时让窗口也全屏
|
||||||
const nc = (window as any).netcatty;
|
const nc = (window as any).netcatty;
|
||||||
@@ -916,6 +1002,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
document.body.classList.remove('monitoring-fullscreen-active');
|
document.body.classList.remove('monitoring-fullscreen-active');
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -923,6 +1010,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
|
|
||||||
const enterFullscreen = useCallback(() => {
|
const enterFullscreen = useCallback(() => {
|
||||||
setIsFullscreen(true);
|
setIsFullscreen(true);
|
||||||
|
// 浏览器远程访问环境:用原生 Fullscreen API 让 body 全屏
|
||||||
|
if (typeof (document.documentElement as any).requestFullscreen === 'function') {
|
||||||
|
(document.documentElement as any).requestFullscreen().catch(() => {});
|
||||||
|
} else if (typeof (document.body as any).webkitRequestFullscreen === 'function') {
|
||||||
|
(document.body as any).webkitRequestFullscreen();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const exitFullscreen = useCallback(() => {
|
const exitFullscreen = useCallback(() => {
|
||||||
@@ -931,6 +1024,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
if (nc?.windowFullscreen) {
|
if (nc?.windowFullscreen) {
|
||||||
nc.windowFullscreen(false).catch(() => {});
|
nc.windowFullscreen(false).catch(() => {});
|
||||||
}
|
}
|
||||||
|
// 浏览器环境退出原生全屏
|
||||||
|
if (document.fullscreenElement && typeof document.exitFullscreen === 'function') {
|
||||||
|
document.exitFullscreen().catch(() => {});
|
||||||
|
} else if (typeof (document as any).webkitExitFullscreen === 'function') {
|
||||||
|
(document as any).webkitExitFullscreen();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
@@ -966,7 +1065,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return hosts.map((h) => {
|
return monitoring.effectiveHosts.map((h) => {
|
||||||
const type = classifyHostType(h);
|
const type = classifyHostType(h);
|
||||||
const online = monitoring.onlineMap[h.id] ?? true;
|
const online = monitoring.onlineMap[h.id] ?? true;
|
||||||
const cpuUsage = monitoring.cpuMap[h.id] ?? 0;
|
const cpuUsage = monitoring.cpuMap[h.id] ?? 0;
|
||||||
@@ -996,9 +1095,14 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
status = 'warning';
|
status = 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// label 里可能已经包含 IP(如 "外网-192.168.0.221"),去重显示
|
||||||
|
let rawName = h.label || h.id;
|
||||||
|
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
|
||||||
|
const name = rawName.replace(ipRegex, '').trim().replace(/[-_\s]+$|^[-_\s]+/g, '') || rawName;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: h.id,
|
id: h.id,
|
||||||
name: h.label || h.name || h.id,
|
name,
|
||||||
ip: h.hostname,
|
ip: h.hostname,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
@@ -1011,7 +1115,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
lastUpdate,
|
lastUpdate,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [hosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap, monitoring.alerts, monitoring.thresholds]);
|
}, [monitoring.effectiveHosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap, monitoring.alerts, monitoring.thresholds]);
|
||||||
|
|
||||||
// 合并自动生成链路和用户自定义链路,并过滤掉源/目标已不存在的僵尸连线
|
// 合并自动生成链路和用户自定义链路,并过滤掉源/目标已不存在的僵尸连线
|
||||||
const links = useMemo(() => {
|
const links = useMemo(() => {
|
||||||
@@ -1706,7 +1810,11 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
const data: Record<string, { x: number; y: number; z: number }> = saved ? JSON.parse(saved) : {};
|
const data: Record<string, { x: number; y: number; z: number }> = saved ? JSON.parse(saved) : {};
|
||||||
data[devId] = { x: p.x, y: clampedY, z: p.z };
|
data[devId] = { x: p.x, y: clampedY, z: p.z };
|
||||||
localStorage.setItem('monitoring-topology-positions', JSON.stringify(data));
|
localStorage.setItem('monitoring-topology-positions', JSON.stringify(data));
|
||||||
console.log('[持久化] 位置已保存:', devId, { x: p.x, y: clampedY, z: p.z });
|
// Electron 环境同步给主进程,让远程浏览器也能读到
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (nc?.inspection?.saveTopologyPositions) {
|
||||||
|
nc.inspection.saveTopologyPositions(data).catch(() => {});
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2183,6 +2291,22 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
const positions: Record<string, THREE.Vector3> = {};
|
const positions: Record<string, THREE.Vector3> = {};
|
||||||
|
|
||||||
const savedPositions = (() => {
|
const savedPositions = (() => {
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
if (IS_REMOTE) {
|
||||||
|
try {
|
||||||
|
const token = (window as any).__REMOTE_TOKEN__ || "";
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", `/api/topology-positions?token=${encodeURIComponent(token)}`, false);
|
||||||
|
xhr.send();
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const json = JSON.parse(xhr.responseText);
|
||||||
|
if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
|
||||||
|
return json.positions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
// 本地 Electron:从 localStorage 读
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('monitoring-topology-positions');
|
const saved = localStorage.getItem('monitoring-topology-positions');
|
||||||
return saved ? JSON.parse(saved) : null;
|
return saved ? JSON.parse(saved) : null;
|
||||||
@@ -2237,6 +2361,20 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
|
|
||||||
positionsRef.current = positions;
|
positionsRef.current = positions;
|
||||||
|
|
||||||
|
// Electron 环境:启动时自动把 localStorage 里的拓扑位置同步给主进程,供远程浏览器读取
|
||||||
|
{
|
||||||
|
const IS_ELECTRON = typeof window !== "undefined" && !!(window as any).netcatty;
|
||||||
|
if (IS_ELECTRON) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('monitoring-topology-positions');
|
||||||
|
if (saved) {
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
nc.inspection?.saveTopologyPositions?.(JSON.parse(saved)).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 创建设备节点
|
// 创建设备节点
|
||||||
const nodes: THREE.Group[] = [];
|
const nodes: THREE.Group[] = [];
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
@@ -3383,6 +3521,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
|||||||
setCustomLinks((prev) => [...prev, newLink]);
|
setCustomLinks((prev) => [...prev, newLink]);
|
||||||
console.log('[连线] 创建链路:', pendingLink.sourceId, '->', pendingLink.targetId, type);
|
console.log('[连线] 创建链路:', pendingLink.sourceId, '->', pendingLink.targetId, type);
|
||||||
setPendingLink(null);
|
setPendingLink(null);
|
||||||
|
setLinkMode(null); // 退出连线模式,恢复 cursor 为 grab
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ interface UseMonitoringDataResult {
|
|||||||
// 操作
|
// 操作
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|
||||||
|
// 浏览器远程访问环境下实际使用的 hosts(Electron 环境 = props.hosts)
|
||||||
|
effectiveHosts: Host[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== localStorage 持久化(降级用,API 不可用时显示缓存) =====
|
// ===== localStorage 持久化(降级用,API 不可用时显示缓存) =====
|
||||||
@@ -321,6 +324,61 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
|
|||||||
hostsRef.current = hosts;
|
hostsRef.current = hosts;
|
||||||
keysRef.current = keys;
|
keysRef.current = keys;
|
||||||
|
|
||||||
|
// 浏览器远程访问环境:从 /api/devices 构建虚拟 hosts 列表(Electron vault 不可用)
|
||||||
|
const [remoteHosts, setRemoteHosts] = useState<Host[]>([]);
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || "";
|
||||||
|
|
||||||
|
// Electron 本地:同步 vault hosts 给主进程(供远程浏览器 API 读)
|
||||||
|
useEffect(() => {
|
||||||
|
if (IS_REMOTE) return; // 远程浏览器不触发
|
||||||
|
const nc = (window as any).netcatty;
|
||||||
|
if (!nc?.inspection) return;
|
||||||
|
if (hosts.length === 0) return;
|
||||||
|
console.log('[useMonitoringData] syncing vault hosts to main process:', hosts.length);
|
||||||
|
nc.inspection.saveVaultHosts(hosts).then((r: any) => {
|
||||||
|
console.log('[useMonitoringData] saveVaultHosts result:', r);
|
||||||
|
}).catch((e: any) => {
|
||||||
|
console.warn('[useMonitoringData] saveVaultHosts error:', e);
|
||||||
|
});
|
||||||
|
}, [hosts, IS_REMOTE]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_REMOTE) return;
|
||||||
|
console.log('[monitoring] REMOTE ENV - props.hosts.length=', hosts.length, 'remoteHosts.length=', remoteHosts.length);
|
||||||
|
if (hosts.length > 0) {
|
||||||
|
console.warn('[monitoring] REMOTE ENV 但 props.hosts 有值!跳过 remote hosts 加载,hosts[0]=', hosts[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadRemoteHosts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/devices?token=${encodeURIComponent(REMOTE_TOKEN)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const list: Host[] = (data.devices || []).map((d: any) => ({
|
||||||
|
id: d.host_id,
|
||||||
|
label: d.label || d.name || d.hostname || d.host_id,
|
||||||
|
hostname: d.hostname || d.name || "",
|
||||||
|
port: 22,
|
||||||
|
username: "",
|
||||||
|
distro: d.distro || d.catalog_type || d.catalog_id || "",
|
||||||
|
type: d.type || "",
|
||||||
|
catalogId: d.catalog_id || "",
|
||||||
|
tags: [],
|
||||||
|
os: "linux" as const,
|
||||||
|
group: "",
|
||||||
|
}));
|
||||||
|
setRemoteHosts(list);
|
||||||
|
hostsRef.current = list;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[monitoring] remote hosts load failed:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadRemoteHosts();
|
||||||
|
}, [IS_REMOTE, REMOTE_TOKEN, hosts.length]);
|
||||||
|
|
||||||
|
// 实际使用的 hosts:Electron 用 props,浏览器用远程加载的
|
||||||
|
const effectiveHosts = IS_REMOTE && hosts.length === 0 ? remoteHosts : hosts;
|
||||||
|
|
||||||
const refresh = useCallback(async (silent = false) => {
|
const refresh = useCallback(async (silent = false) => {
|
||||||
if (loadingRef.current) {
|
if (loadingRef.current) {
|
||||||
console.log('[monitoring] refresh() 正在执行中,跳过');
|
console.log('[monitoring] refresh() 正在执行中,跳过');
|
||||||
@@ -505,33 +563,33 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
|
|||||||
// 自动启动调度器
|
// 自动启动调度器
|
||||||
if (enabledDevices.length === 0) {
|
if (enabledDevices.length === 0) {
|
||||||
console.log('[monitoring] 没有启用巡检的设备');
|
console.log('[monitoring] 没有启用巡检的设备');
|
||||||
} else if (schedulerStatus.running) {
|
|
||||||
console.log('[monitoring] 调度器已在运行中');
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[monitoring] 条件满足,准备启动调度器');
|
console.log('[monitoring] 条件满足,准备启动调度器');
|
||||||
try {
|
try {
|
||||||
const hostConfigs: Record<string, Record<string, unknown>> = {};
|
const hostConfigs: Record<string, Record<string, unknown>> = {};
|
||||||
for (const dev of enabledDevices) {
|
const allExt = allExtensions || {};
|
||||||
const hostname = dev.hostname;
|
for (const [hostId, ext] of Object.entries(allExt)) {
|
||||||
if (!hostname) {
|
if (!ext || !ext.inspection_enabled) continue;
|
||||||
console.log('[monitoring] 设备', dev.host_id, '没有 hostname,跳过');
|
const host = hostsRef.current.find((h) => h.id === hostId);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const host = hostsRef.current.find((h) => h.hostname === hostname);
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
console.log('[monitoring] vault 中未找到 hostname:', hostname, ',跳过');
|
console.log('[monitoring] vault 中未找到 host_id:', hostId, ',跳过');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!host.username) {
|
if (!host.username) {
|
||||||
console.log('[monitoring] 设备', hostname, '缺少 username,跳过');
|
console.log('[monitoring] 设备', host.label || host.hostname || hostId, '缺少 username,跳过');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
hostConfigs[dev.host_id] = buildSshConfig(host, keysRef.current);
|
hostConfigs[hostId] = buildSshConfig(host, keysRef.current);
|
||||||
}
|
}
|
||||||
|
// 诊断:打印 hostConfigs 样本看 label 有没有正确传递
|
||||||
|
const sample = Object.entries(hostConfigs).slice(0, 3).map(([id, c]) => ({ id, label: c.label, host: c.host, username: c.username }));
|
||||||
|
console.log('[monitoring] hostConfigs 样本:', JSON.stringify(sample));
|
||||||
if (Object.keys(hostConfigs).length === 0) {
|
if (Object.keys(hostConfigs).length === 0) {
|
||||||
console.log('[monitoring] 没有可巡检的设备(hostname 未写入或配置不完整)');
|
console.log('[monitoring] 没有可巡检的设备(vault 中匹配不到或凭据不完整)');
|
||||||
} else {
|
} else {
|
||||||
console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备');
|
console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备(running=' + schedulerStatus.running + ')');
|
||||||
|
// 不管 running 与否都传 hostConfigs 过去让主进程更新,
|
||||||
|
// 主进程 execFn 通过模块级 _hostConfigs 引用动态读最新值
|
||||||
await inspectionSchedulerApi.start(hostConfigs);
|
await inspectionSchedulerApi.start(hostConfigs);
|
||||||
setSchedulerRunning(true);
|
setSchedulerRunning(true);
|
||||||
}
|
}
|
||||||
@@ -586,7 +644,7 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
|
|||||||
setOnlineMap((prev) => {
|
setOnlineMap((prev) => {
|
||||||
const next: Record<string, boolean> = { ...prev };
|
const next: Record<string, boolean> = { ...prev };
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const h of hosts) {
|
for (const h of effectiveHosts) {
|
||||||
if (next[h.id] === undefined) {
|
if (next[h.id] === undefined) {
|
||||||
next[h.id] = true; // 假设在线,等巡检结果覆盖
|
next[h.id] = true; // 假设在线,等巡检结果覆盖
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -594,7 +652,7 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
|
|||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
}, [hosts]);
|
}, [effectiveHosts]);
|
||||||
|
|
||||||
// 从告警规则派生阈值(和 alertRuleStore 默认规则对齐)
|
// 从告警规则派生阈值(和 alertRuleStore 默认规则对齐)
|
||||||
const thresholds = useMemo(() => {
|
const thresholds = useMemo(() => {
|
||||||
@@ -636,5 +694,6 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
|
|||||||
thresholds,
|
thresholds,
|
||||||
refresh,
|
refresh,
|
||||||
loading,
|
loading,
|
||||||
|
effectiveHosts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ function VaultConnectionLogsSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||||
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
|
||||||
// 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件
|
// 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件
|
||||||
const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh');
|
const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh');
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
const path = require('node:path');
|
||||||
const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs');
|
const { moshExtraResources } = require('./scripts/mosh-extra-resources.cjs');
|
||||||
const { etExtraResources } = require('./scripts/et-extra-resources.cjs');
|
const { etExtraResources } = require('./scripts/et-extra-resources.cjs');
|
||||||
|
// 说明:electron-builder 依赖收集器在 monorepo/workspace 项目里会漏掉
|
||||||
|
// workspace 子包引用但根 package.json 没声明的运行时依赖(hoist 上来的),
|
||||||
|
// 且追踪传递依赖时容易漏包。这里直接把整个 node_modules 目录纳入 files,
|
||||||
|
// 确保运行时 require 什么都能找到。用排除列表去掉前端/构建工具/文档/测试代码。
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import('electron-builder').Configuration}
|
* @type {import('electron-builder').Configuration}
|
||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
// 内网环境下 electron-builder 默认会去 github.com 下载 Electron zip。
|
||||||
|
// 强制指向本地已解压的 node_modules/electron/dist,跳过联网。
|
||||||
|
electronDist: path.join(__dirname, 'node_modules', 'electron', 'dist'),
|
||||||
appId: 'com.netcatty.app',
|
appId: 'com.netcatty.app',
|
||||||
productName: 'NetMesh',
|
productName: 'NetMesh',
|
||||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||||
@@ -50,117 +58,19 @@ module.exports = {
|
|||||||
output: 'release'
|
output: 'release'
|
||||||
},
|
},
|
||||||
files: [
|
files: [
|
||||||
'dist/**/*',
|
// electron-builder 收集器只读根 package.json,但 workspace 子包的运行时依赖
|
||||||
'electron/**/*',
|
// 会 hoist 到根 node_modules 却没在根 package.json 里声明。
|
||||||
// The Windows Hello helper is shipped once through win.extraResources.
|
// 收集器会漏掉这些包,所以把漏的补进根 package.json 后它就能追踪完整传递依赖树。
|
||||||
// Exclude local per-arch build output from app.asar on every platform.
|
"dist/**/*",
|
||||||
'!electron/bridges/windowsHelloHelper/build/**/*',
|
"electron/**/*",
|
||||||
// Runtime smoke fixtures are built test packages, not host resources.
|
"!electron/bridges/windowsHelloHelper/build/**/*",
|
||||||
// Keep them out of production ASARs so no example plugin can be
|
"!electron/plugins/fixtures/**/*",
|
||||||
// mistaken for an installed or host-trusted package.
|
"infrastructure/config/terminalFlowConstants.cjs",
|
||||||
'!electron/plugins/fixtures/**/*',
|
"infrastructure/config/terminalFlowConstants.json",
|
||||||
// Main-process terminal flow control reads shared thresholds from here
|
"domain/terminalPromptSecurity.shared.cjs",
|
||||||
// (terminalFlowAck.cjs). Must ship beside electron/ in app.asar.
|
"lib/**/*.cjs",
|
||||||
'infrastructure/config/terminalFlowConstants.cjs',
|
"lib/**/*.json",
|
||||||
'infrastructure/config/terminalFlowConstants.json',
|
"skills/**/*"
|
||||||
// Renderer and Mosh's main-process bootstrap share the same
|
|
||||||
// fail-closed prompt classifier at runtime.
|
|
||||||
'domain/terminalPromptSecurity.shared.cjs',
|
|
||||||
'lib/**/*.cjs',
|
|
||||||
'lib/**/*.json',
|
|
||||||
'skills/**/*',
|
|
||||||
'!public/**/*',
|
|
||||||
'!**/*.map',
|
|
||||||
'!**/*.d.ts',
|
|
||||||
'!**/*.d.mts',
|
|
||||||
'!**/*.d.cts',
|
|
||||||
'!**/*.ts',
|
|
||||||
'!**/*.tsx',
|
|
||||||
'!**/*.test.*',
|
|
||||||
'!**/*.spec.*',
|
|
||||||
'!**/__tests__/**/*',
|
|
||||||
'!**/test/**/*',
|
|
||||||
'!**/tests/**/*',
|
|
||||||
'!**/example/**/*',
|
|
||||||
'!**/examples/**/*',
|
|
||||||
'!node_modules/**/docs/**/*',
|
|
||||||
'!node_modules/**/doc/**/*',
|
|
||||||
'!node_modules/**/benchmark/**/*',
|
|
||||||
'!node_modules/**/benchmarks/**/*',
|
|
||||||
// Renderer-only packages are compiled into dist by Vite. Keep them
|
|
||||||
// installed for npm run dev/build, but do not ship the duplicate source
|
|
||||||
// packages in release artifacts.
|
|
||||||
'!node_modules/@fontsource/**/*',
|
|
||||||
'!node_modules/@monaco-editor/**/*',
|
|
||||||
'!node_modules/@radix-ui/**/*',
|
|
||||||
'!node_modules/@xterm/**/*',
|
|
||||||
'!node_modules/lucide-react/**/*',
|
|
||||||
'!node_modules/monaco-editor/**/*',
|
|
||||||
'!node_modules/react/**/*',
|
|
||||||
'!node_modules/react-dom/**/*',
|
|
||||||
'!node_modules/ai/**/*',
|
|
||||||
'!node_modules/@ai-sdk/**/*',
|
|
||||||
'!node_modules/@mdxeditor/**/*',
|
|
||||||
'!node_modules/streamdown/**/*',
|
|
||||||
'!node_modules/@streamdown/**/*',
|
|
||||||
'!node_modules/@tanstack/react-virtual/**/*',
|
|
||||||
'!node_modules/pinyin-pro/**/*',
|
|
||||||
'!node_modules/re2js/**/*',
|
|
||||||
'!node_modules/@eslint-community/regexpp/**/*',
|
|
||||||
'!node_modules/clsx/**/*',
|
|
||||||
'!node_modules/tailwind-merge/**/*',
|
|
||||||
'!node_modules/use-stick-to-bottom/**/*',
|
|
||||||
'!node_modules/lexical/**/*',
|
|
||||||
'!node_modules/@lexical/**/*',
|
|
||||||
'!node_modules/@codemirror/**/*',
|
|
||||||
'!node_modules/katex/**/*',
|
|
||||||
'!node_modules/shiki/**/*',
|
|
||||||
'!node_modules/@shiki/**/*',
|
|
||||||
// Heavy cloud completion specs are intentionally not bundled. The main
|
|
||||||
// process filters the same prefixes so dev and packaged builds behave
|
|
||||||
// consistently.
|
|
||||||
'!node_modules/@withfig/autocomplete/build/aws.js',
|
|
||||||
'!node_modules/@withfig/autocomplete/build/aws/**/*',
|
|
||||||
'!node_modules/@withfig/autocomplete/build/gcloud.js',
|
|
||||||
'!node_modules/@withfig/autocomplete/build/gcloud/**/*',
|
|
||||||
'!node_modules/@withfig/autocomplete/build/az/**/*',
|
|
||||||
// Fig specs are already compiled JavaScript; TypeScript is only pulled
|
|
||||||
// in by Fig helper packages as build tooling and is not needed at app
|
|
||||||
// runtime.
|
|
||||||
'!node_modules/typescript/**/*',
|
|
||||||
// ── Exclude per-platform native agent binaries (~100s of MB each). ──
|
|
||||||
// Netcatty is "bring your own CLI": each SDK is pointed at the user's
|
|
||||||
// system-installed CLI via an absolute path override (claude
|
|
||||||
// pathToClaudeCodeExecutable / codex codexPathOverride / copilot cliPath).
|
|
||||||
// Only the SDKs' JS is bundled; the heavy per-arch binaries are dropped.
|
|
||||||
// NOTE: claude-agent-sdk vendors the `claude` binary as a NESTED package
|
|
||||||
// (claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-<arch>),
|
|
||||||
// so this exclusion must match at any depth (**/), not just top-level.
|
|
||||||
// The codex/copilot exclusions target only the per-arch binary packages
|
|
||||||
// (codex-<arch> / copilot-<arch>) — NOT @openai/codex-sdk / copilot-sdk,
|
|
||||||
// whose JS we DO bundle. @github/copilot is the full ~288MB CLI (with
|
|
||||||
// per-platform prebuilds); netcatty uses the user's copilot via cliPath,
|
|
||||||
// so it is excluded entirely.
|
|
||||||
'!**/@anthropic-ai/claude-agent-sdk-*/**/*',
|
|
||||||
'!node_modules/@anthropic-ai/claude-code-*/**/*',
|
|
||||||
'!node_modules/@openai/codex-{darwin,linux,linuxmusl,win32}-*/**/*',
|
|
||||||
'!node_modules/@github/copilot-{darwin,linux,linuxmusl,win32}-*/**/*',
|
|
||||||
'!node_modules/@github/copilot/**/*',
|
|
||||||
'!node_modules/opencode-{darwin,linux,linuxmusl,windows}-*/**/*',
|
|
||||||
'!node_modules/opencode-ai/**/*',
|
|
||||||
// CodeBuddy follows the same first-party integration model as the
|
|
||||||
// other coding agents: Netcatty discovers and passes the user's
|
|
||||||
// installed CLI path to the SDK. Keep the small SDK wrapper, but do not
|
|
||||||
// bundle the full CodeBuddy CLI payload (rg vendors + web UI).
|
|
||||||
'!node_modules/@tencent-ai/agent-sdk/cli/**/*',
|
|
||||||
// Netcatty loads Cursor SDK through ESM dynamic import, so the duplicate
|
|
||||||
// CommonJS build and type metadata are not needed at runtime.
|
|
||||||
'!node_modules/@cursor/sdk/dist/cjs/**/*',
|
|
||||||
'!node_modules/@cursor/sdk/dist/**/*.d.ts',
|
|
||||||
'!node_modules/@cursor/sdk/dist/**/*.d.ts.map',
|
|
||||||
// sqlite3 rebuilds a native module for Electron; its upstream source
|
|
||||||
// tarball is build-time payload only.
|
|
||||||
'!node_modules/sqlite3/deps/**/*'
|
|
||||||
],
|
],
|
||||||
asarUnpack: [
|
asarUnpack: [
|
||||||
'node_modules/node-pty/**/*',
|
'node_modules/node-pty/**/*',
|
||||||
|
|||||||
@@ -241,6 +241,19 @@ const activeStreams = new Map();
|
|||||||
// ── Provider registry (synced from renderer, keys stay encrypted) ──
|
// ── Provider registry (synced from renderer, keys stay encrypted) ──
|
||||||
const ENC_PREFIX = "enc:v1:";
|
const ENC_PREFIX = "enc:v1:";
|
||||||
let providerConfigs = [];
|
let providerConfigs = [];
|
||||||
|
const _PROVIDER_STORAGE_KEY = "_ai_provider_configs";
|
||||||
|
|
||||||
|
function loadProviderConfigsFromStorage() {
|
||||||
|
try {
|
||||||
|
const { readStorage } = require("./inspection/inspectionStorage.cjs");
|
||||||
|
const raw = readStorage(_PROVIDER_STORAGE_KEY);
|
||||||
|
if (Array.isArray(raw) && raw.length > 0) {
|
||||||
|
providerConfigs = raw;
|
||||||
|
console.log("[aiBridge] 从 inspectionStorage 加载 providerConfigs:", raw.length, "条");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return providerConfigs;
|
||||||
|
}
|
||||||
// Web search config (synced from renderer — apiKey stays encrypted, decrypted on use)
|
// Web search config (synced from renderer — apiKey stays encrypted, decrypted on use)
|
||||||
let webSearchApiHost = null;
|
let webSearchApiHost = null;
|
||||||
let webSearchApiKeyEncrypted = null;
|
let webSearchApiKeyEncrypted = null;
|
||||||
@@ -930,7 +943,10 @@ function createHandlerContext(ipcMain) {
|
|||||||
get cliDiscoveryFilePath() { return cliDiscoveryFilePath; },
|
get cliDiscoveryFilePath() { return cliDiscoveryFilePath; },
|
||||||
set cliDiscoveryFilePath(value) { cliDiscoveryFilePath = value; },
|
set cliDiscoveryFilePath(value) { cliDiscoveryFilePath = value; },
|
||||||
activeStreams,
|
activeStreams,
|
||||||
get providerConfigs() { return providerConfigs; },
|
get providerConfigs() {
|
||||||
|
if (!providerConfigs || providerConfigs.length === 0) loadProviderConfigsFromStorage();
|
||||||
|
return providerConfigs;
|
||||||
|
},
|
||||||
set providerConfigs(value) { providerConfigs = value; },
|
set providerConfigs(value) { providerConfigs = value; },
|
||||||
get webSearchApiHost() { return webSearchApiHost; },
|
get webSearchApiHost() { return webSearchApiHost; },
|
||||||
set webSearchApiHost(value) { webSearchApiHost = value; },
|
set webSearchApiHost(value) { webSearchApiHost = value; },
|
||||||
@@ -987,6 +1003,21 @@ function registerHandlers(ipcMain) {
|
|||||||
if (externalMcpController) {
|
if (externalMcpController) {
|
||||||
externalMcpController.registerHandlers(ipcMain, validateSenderOrSettings);
|
externalMcpController.registerHandlers(ipcMain, validateSenderOrSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动时从 inspectionStorage 加载持久化的 provider configs
|
||||||
|
// 解决重启后或远程浏览器场景下 providerConfigs 为空的问题
|
||||||
|
try {
|
||||||
|
const inspectionStorage = require("./inspection/inspectionStorage.cjs");
|
||||||
|
const raw = inspectionStorage.readStorage("_ai_provider_configs");
|
||||||
|
if (Array.isArray(raw) && raw.length > 0) {
|
||||||
|
providerConfigs = raw;
|
||||||
|
console.log("[aiBridge] 启动时从 inspectionStorage 加载 providerConfigs:", raw.length, "条");
|
||||||
|
} else {
|
||||||
|
console.log("[aiBridge] inspectionStorage 中无持久化 providerConfigs(renderer 还未 sync 过)");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[aiBridge] 启动时加载 providerConfigs 失败:", e?.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Abort active streams and child processes on shutdown
|
// Abort active streams and child processes on shutdown
|
||||||
@@ -1046,4 +1077,9 @@ module.exports = {
|
|||||||
DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS,
|
DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS,
|
||||||
getExternalMcpController: () => externalMcpController,
|
getExternalMcpController: () => externalMcpController,
|
||||||
get aiBridgeContext() { return registeredContext; },
|
get aiBridgeContext() { return registeredContext; },
|
||||||
|
get providerConfigs() { return providerConfigs; },
|
||||||
|
resolveProviderApiKey,
|
||||||
|
injectApiKeyIntoRequest,
|
||||||
|
shouldSkipTLSVerify,
|
||||||
|
decryptApiKeyValue,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ function registerProviderHandlers(ctx) {
|
|||||||
if (Array.isArray(providers)) {
|
if (Array.isArray(providers)) {
|
||||||
providerConfigs = providers;
|
providerConfigs = providers;
|
||||||
rebuildProviderFetchHosts();
|
rebuildProviderFetchHosts();
|
||||||
|
// 持久化到 inspectionStorage,让 remoteHttpServer / 主进程内部 RCA 能读到
|
||||||
|
try {
|
||||||
|
const { writeStorage } = require("../inspection/inspectionStorage.cjs");
|
||||||
|
writeStorage("_ai_provider_configs", providers);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 告警记录存储
|
* 告警记录存储
|
||||||
@@ -45,8 +45,10 @@ function _ensureLoaded(readFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _safeParse(text, def) {
|
function _safeParse(text, def) {
|
||||||
|
if (text === null || text === undefined) return def;
|
||||||
|
if (typeof text === "object") return text;
|
||||||
try { return JSON.parse(text); }
|
try { return JSON.parse(text); }
|
||||||
catch (e) { return def; }
|
catch { return def; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function _saveAlerts(writeFn) {
|
function _saveAlerts(writeFn) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 告警规则数据存储
|
* 告警规则数据存储
|
||||||
@@ -33,8 +33,10 @@ function _ensureLoaded(readFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _safeParse(text, def) {
|
function _safeParse(text, def) {
|
||||||
|
if (text === null || text === undefined) return def;
|
||||||
|
if (typeof text === "object") return text;
|
||||||
try { return JSON.parse(text); }
|
try { return JSON.parse(text); }
|
||||||
catch (e) { return def; }
|
catch { return def; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function _save(writeFn) {
|
function _save(writeFn) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 日运行报告存储 + 聚合
|
* 日运行报告存储 + 聚合
|
||||||
@@ -30,6 +30,8 @@ const { STORAGE_KEY_DAILY_REPORTS } = require("./storageKeys.cjs");
|
|||||||
const MAX_DAILY_REPORTS = 120; // 保留最近约 4 个月(每天 1 条)
|
const MAX_DAILY_REPORTS = 120; // 保留最近约 4 个月(每天 1 条)
|
||||||
|
|
||||||
function _safeParse(raw, def) {
|
function _safeParse(raw, def) {
|
||||||
|
if (raw === null || raw === undefined) return def;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
try { return JSON.parse(raw); }
|
try { return JSON.parse(raw); }
|
||||||
catch { return def; }
|
catch { return def; }
|
||||||
}
|
}
|
||||||
@@ -276,6 +278,8 @@ function clearAll(readFn, writeFn) {
|
|||||||
_save(writeFn);
|
_save(writeFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _resetForTest() { _reports = null; }
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
MAX_DAILY_REPORTS,
|
MAX_DAILY_REPORTS,
|
||||||
buildDailyReport,
|
buildDailyReport,
|
||||||
@@ -286,4 +290,5 @@ module.exports = {
|
|||||||
_dayStart,
|
_dayStart,
|
||||||
_dayEnd,
|
_dayEnd,
|
||||||
_dateStr,
|
_dateStr,
|
||||||
|
_resetForTest,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ function _ensureLoaded(readFn) {
|
|||||||
|
|
||||||
function _safeParse(text, def) {
|
function _safeParse(text, def) {
|
||||||
if (text === null || text === undefined) return def;
|
if (text === null || text === undefined) return def;
|
||||||
|
// 已经是对象/数组 → 直接返回
|
||||||
|
if (typeof text === "object") return text;
|
||||||
|
if (typeof text !== "string") return def;
|
||||||
let parsed;
|
let parsed;
|
||||||
try { parsed = JSON.parse(text); }
|
try { parsed = JSON.parse(text); }
|
||||||
catch (e) { return def; }
|
catch (e) { return def; }
|
||||||
|
|||||||
@@ -36,16 +36,26 @@ let _scheduler = null;
|
|||||||
let _isSnapshotRunning = false; // 巡检执行状态(主进程持有,renderer 可查询)
|
let _isSnapshotRunning = false; // 巡检执行状态(主进程持有,renderer 可查询)
|
||||||
|
|
||||||
// 读报告产品名(PDF 巡检报告标题专用)
|
// 读报告产品名(PDF 巡检报告标题专用)
|
||||||
// 优先级:netcatty_report_product_name_v1 > netcatty_product_name_v1 > 默认值
|
// 优先级:_report_product_name(新)> _product_name(新)> report_product_name_v1(旧带前缀)> product_name_v1(旧带前缀)> 默认值
|
||||||
function _readProductName(readFn) {
|
function _readProductName(readFn) {
|
||||||
|
// 新 key(IPC save 写的)
|
||||||
|
try {
|
||||||
|
const raw = readFn("_report_product_name");
|
||||||
|
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
const raw = readFn("_product_name");
|
||||||
|
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||||
|
} catch (_) {}
|
||||||
|
// 旧带前缀 key(可能 JSON.stringify 过)
|
||||||
try {
|
try {
|
||||||
const raw = readFn("netcatty_report_product_name_v1");
|
const raw = readFn("netcatty_report_product_name_v1");
|
||||||
const name = raw ? JSON.parse(raw) : null;
|
const name = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
|
||||||
if (typeof name === "string" && name.trim()) return name.trim();
|
if (typeof name === "string" && name.trim()) return name.trim();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
const raw = readFn("netcatty_product_name_v1");
|
const raw = readFn("netcatty_product_name_v1");
|
||||||
const name = raw ? JSON.parse(raw) : null;
|
const name = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
|
||||||
if (typeof name === "string" && name.trim()) return name.trim();
|
if (typeof name === "string" && name.trim()) return name.trim();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return process.env.NETCATTY_PRODUCT_NAME || "NetMesh 智能巡检系统";
|
return process.env.NETCATTY_PRODUCT_NAME || "NetMesh 智能巡检系统";
|
||||||
@@ -185,25 +195,28 @@ function _buildSnapshotPrompt(snapshot) {
|
|||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
offlineDevices.push({
|
offlineDevices.push({
|
||||||
device: d.hostname,
|
device: d.hostname,
|
||||||
reason: (cmds[0]?.stderr || cmds[0]?.stdout || "SSH连接失败").slice(0, 200),
|
reason: (cmds[0]?.stderr || cmds[0]?.stdout || "SSH连接失败").slice(0, 150),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// 大幅压缩:只保留失败命令 + 最多 5 条关键资源命令,输出截断到 200 字符
|
||||||
const detailCmds = [];
|
const detailCmds = [];
|
||||||
let hasFail = false;
|
let hasFail = false;
|
||||||
|
let resourceCount = 0;
|
||||||
|
const MAX_RESOURCE = 5;
|
||||||
for (const c of cmds) {
|
for (const c of cmds) {
|
||||||
if (!c.ok) {
|
if (!c.ok) {
|
||||||
hasFail = true;
|
hasFail = true;
|
||||||
detailCmds.push({
|
detailCmds.push({
|
||||||
cmd: c.cmd, purpose: c.purpose, status: "fail",
|
cmd: c.cmd, status: "fail",
|
||||||
output: (c.stdout || c.stderr || "").slice(0, 600),
|
output: (c.stdout || c.stderr || "").slice(0, 200),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cls = _classifyCmd(c.cmd);
|
const cls = _classifyCmd(c.cmd);
|
||||||
if (cls === "resource") {
|
// 只保留 resource 类型(cpu/mem/磁盘/端口等关键指标),跳过 log 类型
|
||||||
detailCmds.push({ cmd: c.cmd, purpose: c.purpose, status: "ok", output: (c.stdout || "").slice(0, 400) });
|
if (cls === "resource" && resourceCount < MAX_RESOURCE) {
|
||||||
} else if (cls === "log") {
|
resourceCount++;
|
||||||
detailCmds.push({ cmd: c.cmd, purpose: c.purpose, status: "ok", output: _truncateStdout(c.cmd, c.stdout || "") });
|
detailCmds.push({ cmd: c.cmd, status: "ok", output: (c.stdout || "").slice(0, 200) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onlineDevices.push({
|
onlineDevices.push({
|
||||||
@@ -218,17 +231,20 @@ function _buildSnapshotPrompt(snapshot) {
|
|||||||
}
|
}
|
||||||
const totalCmds = onlineDevices.reduce((a, d) => a + d.command_total, 0);
|
const totalCmds = onlineDevices.reduce((a, d) => a + d.command_total, 0);
|
||||||
const failCmds = onlineDevices.reduce((a, d) => a + d.command_failed, 0);
|
const failCmds = onlineDevices.reduce((a, d) => a + d.command_failed, 0);
|
||||||
|
// 压缩 JSON:用 null 替代缩进,减少 30-40% 体积
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
timestamp: new Date(snapshot.timestamp).toISOString(),
|
offline: offlineDevices,
|
||||||
trigger: snapshot.trigger,
|
online: onlineDevices,
|
||||||
duration_ms: snapshot.duration_ms,
|
summary: {
|
||||||
offline_count: offlineDevices.length,
|
ts: new Date(snapshot.timestamp).toISOString(),
|
||||||
online_count: onlineDevices.length,
|
trigger: snapshot.trigger,
|
||||||
total_commands: totalCmds,
|
dur_ms: snapshot.duration_ms,
|
||||||
failed_commands: failCmds,
|
offline_n: offlineDevices.length,
|
||||||
offline_devices: offlineDevices,
|
online_n: onlineDevices.length,
|
||||||
online_devices: onlineDevices,
|
total_cmd: totalCmds,
|
||||||
}, null, 2);
|
fail_cmd: failCmds,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _resolveAiStyle(provider) {
|
function _resolveAiStyle(provider) {
|
||||||
@@ -411,6 +427,25 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
return deviceStore.upsertExtension(readFn, writeFn, hostId, data);
|
return deviceStore.upsertExtension(readFn, writeFn, hostId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 批量设置所有设备的 inspection_enabled
|
||||||
|
ipcMain.handle("inspection:batchSetInspectionEnabled", (_e, enabled) => {
|
||||||
|
try {
|
||||||
|
const all = deviceStore.getAllExtensions(readFn);
|
||||||
|
let count = 0;
|
||||||
|
for (const ext of Object.values(all)) {
|
||||||
|
deviceStore.upsertExtension(readFn, writeFn, ext.host_id, {
|
||||||
|
inspection_enabled: !!enabled,
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
console.log(`[Inspection] 批量设置巡检状态: ${enabled ? "启用" : "停止"} ${count} 台设备`);
|
||||||
|
return { ok: true, count, enabled: !!enabled };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Inspection] 批量设置巡检状态失败:", e);
|
||||||
|
return { ok: false, error: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("inspection:deleteExtension", (_e, hostId) => {
|
ipcMain.handle("inspection:deleteExtension", (_e, hostId) => {
|
||||||
return deviceStore.deleteExtension(readFn, writeFn, hostId);
|
return deviceStore.deleteExtension(readFn, writeFn, hostId);
|
||||||
});
|
});
|
||||||
@@ -811,6 +846,318 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- AI RCA 完整实现(主进程版) ----------
|
||||||
|
// 用 _hostConfigs 直接拿 SSH 凭据,不需要 renderer 解密
|
||||||
|
// 支持 IPC handler(Electron 本地)和 remoteHttpServer 直接调用
|
||||||
|
const bridge = require("../aiBridge.cjs");
|
||||||
|
|
||||||
|
// 从 AI 响应中提取 JSON(剥离 <think>...</think>、```json 包裹、找第一个配对的 {...})
|
||||||
|
function _extractJson(text) {
|
||||||
|
let cleaned = (text || "")
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/gi, "") // 剥离思维链
|
||||||
|
.replace(/```json\s*/gi, "")
|
||||||
|
.replace(/```\s*/g, "")
|
||||||
|
.trim();
|
||||||
|
// 直接尝试 parse
|
||||||
|
try { return JSON.parse(cleaned); } catch {}
|
||||||
|
// 找第一个配对的 {...}
|
||||||
|
const openIdx = cleaned.indexOf("{");
|
||||||
|
if (openIdx >= 0) {
|
||||||
|
let depth = 0, closeIdx = -1;
|
||||||
|
for (let i = openIdx; i < cleaned.length; i++) {
|
||||||
|
if (cleaned[i] === "{") depth++;
|
||||||
|
else if (cleaned[i] === "}") { depth--; if (depth === 0) { closeIdx = i; break; } }
|
||||||
|
}
|
||||||
|
if (closeIdx > openIdx) {
|
||||||
|
const jsonStr = cleaned.slice(openIdx, closeIdx + 1);
|
||||||
|
try { return JSON.parse(jsonStr); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("无法从 AI 响应中提取 JSON,原始响应前 200 字: " + cleaned.slice(0, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASE1_SYSTEM = [
|
||||||
|
`你是 NetMesh 运维平台的 AI 诊断规划专家。`,
|
||||||
|
`## 设备 Catalog 与命令族`,
|
||||||
|
`| catalogId | 设备类型 | Shell | 推荐诊断命令 |`,
|
||||||
|
`|---|---|---|---|`,
|
||||||
|
`| linux | Linux 服务器 | bash/sh | top -bn1 | head -20, vmstat 1 2, free -m, cat /proc/meminfo | head -20, df -h, ps aux --sort=-%cpu | head -10, iostat -x 1 1 |`,
|
||||||
|
`| huawei_h3c_switch | 华为/H3C 交换机 | HUAWEI CLI | screen-length 0 temporary, display version, display device, display memory, display power, display fan, display logbuffer level warning size 512 |`,
|
||||||
|
`| cisco_switch | Cisco 交换机 | IOS CLI | terminal length 0, show version, show processes cpu sorted, show memory summary, show interfaces brief, show log tail 50 |`,
|
||||||
|
`## 输出格式(严格 JSON)`,
|
||||||
|
`{ "commands": [{ "command": "xxx", "purpose": "xxx" }], "reasoning": "一句话说明" }`,
|
||||||
|
`## 规则`,
|
||||||
|
`- 命令必须严格匹配 catalogId,不得臆造`,
|
||||||
|
`- 禁止 display cpu-usage(有刷新动画抓不到完整输出)`,
|
||||||
|
`- 华为/H3C 必须先加 screen-length 0 temporary 关闭分页`,
|
||||||
|
`- 不超过 6 条命令`,
|
||||||
|
`- 禁止输出外网 URL / 厂商文档链接`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const PHASE2_SYSTEM = [
|
||||||
|
`你是 NetMesh 智慧运维平台的 AI 根因分析专家。`,
|
||||||
|
`你收到了告警 + 设备巡检 metrics + 多条诊断命令执行输出。`,
|
||||||
|
`## 输出格式(严格 JSON)`,
|
||||||
|
`{`,
|
||||||
|
` "confidence": 85,`,
|
||||||
|
` "rootCauses": ["最可能根因", "次要可能性"],`,
|
||||||
|
` "summary": "2-3 句摘要",`,
|
||||||
|
` "recommendations": [`,
|
||||||
|
` { "priority": "immediate|short_term|long_term", "action": "要做什么", "command": "可选命令", "risk": "low|medium|high" }`,
|
||||||
|
` ]`,
|
||||||
|
`}`,
|
||||||
|
`## 规则`,
|
||||||
|
`- confidence 基于证据充分性`,
|
||||||
|
`- recommendations 至少 2 条最多 5 条,按紧急程度排序`,
|
||||||
|
`- 严禁输出外网 URL / 厂商文档 / 外部工具推荐`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
async function _callAiForRCA(providerConfigsOrNull, systemPrompt, userContent, maxTokens = 2000) {
|
||||||
|
if (!providerConfigsOrNull || !providerConfigsOrNull.length) {
|
||||||
|
throw new Error("AI provider 未配置");
|
||||||
|
}
|
||||||
|
const provider = providerConfigsOrNull.find(p => p.enabled !== false);
|
||||||
|
if (!provider) throw new Error("没有启用的 AI provider");
|
||||||
|
const resolved = bridge.resolveProviderApiKey(provider.id);
|
||||||
|
if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
|
||||||
|
|
||||||
|
const url = provider.endpoint || "https://api.minimaxi.com/v1/chat/completions";
|
||||||
|
const model = provider.model || "MiniMax-M1";
|
||||||
|
const patched = bridge.injectApiKeyIntoRequest(url, {}, provider.id);
|
||||||
|
|
||||||
|
const http = require("http"), https = require("https");
|
||||||
|
const lib = patched.url.startsWith("https") ? https : http;
|
||||||
|
const u = new URL(patched.url);
|
||||||
|
const bodyStr = JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userContent },
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const req = lib.request({
|
||||||
|
hostname: u.hostname,
|
||||||
|
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
||||||
|
path: u.pathname + u.search,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${resolved.apiKey}`,
|
||||||
|
},
|
||||||
|
timeout: 90000,
|
||||||
|
}, res => {
|
||||||
|
let chunks = [];
|
||||||
|
res.on("data", c => chunks.push(c));
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const text = Buffer.concat(chunks).toString();
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
const content = parsed.choices?.[0]?.message?.content
|
||||||
|
|| parsed.content?.[0]?.text
|
||||||
|
|| parsed.candidates?.[0]?.content?.parts?.[0]?.text
|
||||||
|
|| "";
|
||||||
|
resolve(content.trim());
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error("AI 响应解析失败: " + e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.on("timeout", () => { req.destroy(); reject(new Error("AI 请求超时")); });
|
||||||
|
req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAlertRCA(alertId, providerConfigsOverride) {
|
||||||
|
const start = Date.now();
|
||||||
|
const results = { toolInvocations: [] };
|
||||||
|
|
||||||
|
function resetCaches() {
|
||||||
|
try { deviceStore._resetForTest?.(); } catch {}
|
||||||
|
try { alertRecordStore._resetForTest?.(); } catch {}
|
||||||
|
try { inspectionResultStore._resetForTest?.(); } catch {}
|
||||||
|
try { alertRuleStore._resetForTest?.(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 找告警
|
||||||
|
resetCaches();
|
||||||
|
const alerts = alertRecordStore.listAlerts(readFn, { limit: 500 });
|
||||||
|
const alert = alerts.find(a => a.id === alertId);
|
||||||
|
if (!alert) return { success: false, error: "告警不存在", durationMs: 0, toolInvocations: [] };
|
||||||
|
|
||||||
|
// 2. 找 hostConfig
|
||||||
|
let hostConfig = null;
|
||||||
|
if (_hostConfigs && _hostConfigs[alert.device_id]) {
|
||||||
|
const hc = _hostConfigs[alert.device_id];
|
||||||
|
hostConfig = {
|
||||||
|
hostname: hc.host || hc.hostname,
|
||||||
|
port: hc.port || 22,
|
||||||
|
username: hc.username,
|
||||||
|
password: hc.password,
|
||||||
|
privateKeyContent: hc.privateKey || hc.privateKeyContent,
|
||||||
|
passphrase: hc.passphrase,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 找 catalog_id + 巡检 metrics
|
||||||
|
let catalogId = "";
|
||||||
|
const extMap = deviceStore.getAllExtensions(readFn);
|
||||||
|
if (extMap[alert.device_id]?.catalog_id) catalogId = extMap[alert.device_id].catalog_id;
|
||||||
|
|
||||||
|
let metrics = {};
|
||||||
|
const latest = inspectionResultStore.getLatestAll(readFn);
|
||||||
|
const result = latest.find(r => r?.device_id === alert.device_id);
|
||||||
|
if (result) metrics = result.metrics || {};
|
||||||
|
|
||||||
|
const alertInfo = {
|
||||||
|
device_name: alert.device_name,
|
||||||
|
device_id: alert.device_id,
|
||||||
|
level: alert.level,
|
||||||
|
metric: alert.metric,
|
||||||
|
metric_value: alert.metric_value,
|
||||||
|
threshold: alert.threshold,
|
||||||
|
rule_name: alert.rule_name,
|
||||||
|
triggered_at: alert.triggered_at,
|
||||||
|
message: alert.message,
|
||||||
|
catalog_id: catalogId || "unknown",
|
||||||
|
online: !!result?.online,
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerConfigs = providerConfigsOverride || bridge.providerConfigs;
|
||||||
|
|
||||||
|
// 4. Phase 1 — AI 规划诊断命令
|
||||||
|
let phase1Plan = { commands: [] };
|
||||||
|
try {
|
||||||
|
const phase1Input = JSON.stringify({
|
||||||
|
alert: alertInfo,
|
||||||
|
metrics,
|
||||||
|
catalog_id: catalogId,
|
||||||
|
device_context: `设备名: ${alert.device_name}, 设备ID: ${alert.device_id}`,
|
||||||
|
});
|
||||||
|
const phase1Text = await _callAiForRCA(providerConfigs, PHASE1_SYSTEM, phase1Input, 1000);
|
||||||
|
const js = _extractJson(phase1Text);
|
||||||
|
phase1Plan = js;
|
||||||
|
console.log("[RCA] Phase 1 规划:", phase1Plan.commands?.length, "条命令");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[RCA] Phase 1 失败,跳过 SSH 诊断:", e.message);
|
||||||
|
results.toolInvocations.push({ tool: "phase1_planning", ok: false, durationMs: Date.now() - start, error: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. SSH 执行诊断命令(仅当 hostConfig 可用时)
|
||||||
|
const evidence = [];
|
||||||
|
let allCmdOutputs = "";
|
||||||
|
if (hostConfig && phase1Plan.commands?.length) {
|
||||||
|
console.log("[RCA] 开始 SSH 诊断,目标:", hostConfig.hostname, "命令数:", phase1Plan.commands.length);
|
||||||
|
for (const cmd of phase1Plan.commands) {
|
||||||
|
const cmdStart = Date.now();
|
||||||
|
try {
|
||||||
|
const sshRes = await execSshCommand({
|
||||||
|
host: hostConfig.hostname,
|
||||||
|
port: hostConfig.port || 22,
|
||||||
|
username: hostConfig.username,
|
||||||
|
password: hostConfig.password,
|
||||||
|
privateKey: hostConfig.privateKeyContent,
|
||||||
|
passphrase: hostConfig.passphrase,
|
||||||
|
}, cmd.command, { connectTimeoutMs: 15000, execTimeoutMs: 20000 });
|
||||||
|
const output = (sshRes || "").toString();
|
||||||
|
const truncated = output.length > 2000 ? output.slice(-2000) : output;
|
||||||
|
allCmdOutputs += `\n### ${cmd.command}\n${truncated}\n`;
|
||||||
|
evidence.push({
|
||||||
|
tool: "ssh_command",
|
||||||
|
summary: `${cmd.command}: ${output.length} 字节`,
|
||||||
|
keyFindings: [truncated.slice(0, 300)],
|
||||||
|
});
|
||||||
|
results.toolInvocations.push({ tool: cmd.command, ok: true, durationMs: Date.now() - cmdStart });
|
||||||
|
} catch (e) {
|
||||||
|
results.toolInvocations.push({ tool: cmd.command, ok: false, durationMs: Date.now() - cmdStart, error: e.message });
|
||||||
|
evidence.push({ tool: "ssh_command", summary: `${cmd.command}: 失败 - ${e.message}`, keyFindings: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!hostConfig) {
|
||||||
|
console.warn("[RCA] 无 hostConfig,跳过 SSH 诊断");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Phase 2 — AI 综合根因分析
|
||||||
|
try {
|
||||||
|
const phase2Input = JSON.stringify({
|
||||||
|
alert: alertInfo,
|
||||||
|
metrics,
|
||||||
|
catalog_id: catalogId,
|
||||||
|
command_outputs: allCmdOutputs.slice(0, 8000),
|
||||||
|
});
|
||||||
|
const phase2Text = await _callAiForRCA(providerConfigs, PHASE2_SYSTEM, phase2Input, 2000);
|
||||||
|
const phase2Json = _extractJson(phase2Text);
|
||||||
|
|
||||||
|
const rcaPayload = JSON.stringify({
|
||||||
|
__rca_json: true,
|
||||||
|
generated_at: Date.now(),
|
||||||
|
source: "ai_full",
|
||||||
|
summary: phase2Json.summary,
|
||||||
|
confidence: phase2Json.confidence,
|
||||||
|
rootCauses: phase2Json.rootCauses,
|
||||||
|
evidence,
|
||||||
|
recommendations: phase2Json.recommendations,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 写回告警 note
|
||||||
|
try {
|
||||||
|
resetCaches();
|
||||||
|
const rawAlerts = readFn("netcatty_inspection_alert_records_v1");
|
||||||
|
let alertArr = typeof rawAlerts === "string" ? JSON.parse(rawAlerts || "[]") : (rawAlerts || []);
|
||||||
|
alertArr = alertArr.map(a => a.id === alertId ? { ...a, note: "[RCA_JSON]" + rcaPayload } : a);
|
||||||
|
writeFn("netcatty_inspection_alert_records_v1", JSON.stringify(alertArr));
|
||||||
|
console.log("[RCA] 结果已写回告警 note");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[RCA] 写回告警失败:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
rca: {
|
||||||
|
alertId,
|
||||||
|
deviceId: alert.device_id,
|
||||||
|
deviceName: alert.device_name,
|
||||||
|
confidence: phase2Json.confidence ?? 0,
|
||||||
|
rootCauses: phase2Json.rootCauses || [],
|
||||||
|
evidence,
|
||||||
|
recommendations: phase2Json.recommendations || [],
|
||||||
|
summary: phase2Json.summary || "",
|
||||||
|
},
|
||||||
|
source: "ai",
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
toolInvocations: results.toolInvocations,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[RCA] Phase 2 综合失败:", e.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Phase 2 AI 综合失败: " + e.message,
|
||||||
|
rca: null,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
toolInvocations: results.toolInvocations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册为 IPC handler(Electron 本地备用,主进程直接调用)
|
||||||
|
ipcMain.handle("inspection:runAlertRCA", async (_e, alertId) => {
|
||||||
|
try {
|
||||||
|
const result = await runAlertRCA(alertId);
|
||||||
|
return { ok: result.success, ...result };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e?.message || String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导出给 remoteHttpServer 直接调用(同一个主进程,共享 _hostConfigs 和存储)
|
||||||
|
module.exports.runAlertRCA = runAlertRCA;
|
||||||
|
|
||||||
// ---------- 巡检报告快照 ----------
|
// ---------- 巡检报告快照 ----------
|
||||||
// runReportSnapshot: renderer 侧解密 vault 构建 hostConfig,传入主进程执行报告命令组
|
// runReportSnapshot: renderer 侧解密 vault 构建 hostConfig,传入主进程执行报告命令组
|
||||||
// payload: {
|
// payload: {
|
||||||
@@ -872,6 +1219,7 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
// 内部函数:执行一次快照(IPC handler + reportScheduler 共用)
|
// 内部函数:执行一次快照(IPC handler + reportScheduler 共用)
|
||||||
async function _executeSnapshot(deviceTargets, trigger = "manual") {
|
async function _executeSnapshot(deviceTargets, trigger = "manual") {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
// 打印唯一调用栈前几帧 —— 一眼看出是谁调的
|
// 打印唯一调用栈前几帧 —— 一眼看出是谁调的
|
||||||
const stack = new Error().stack?.split("\n").slice(2, 5).join("\n ") || "";
|
const stack = new Error().stack?.split("\n").slice(2, 5).join("\n ") || "";
|
||||||
console.error(`[EXEC_SNAPSHOT] ════════════════════════════════════`);
|
console.error(`[EXEC_SNAPSHOT] ════════════════════════════════════`);
|
||||||
@@ -1033,7 +1381,7 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
|
|
||||||
// 自动触发 AI 分析 —— 不阻塞快照返回,后台独立执行
|
// 自动触发 AI 分析 —— 不阻塞快照返回,后台独立执行
|
||||||
const bridge = require("../aiBridge.cjs");
|
const bridge = require("../aiBridge.cjs");
|
||||||
const activeProviders = bridge.aiBridgeContext?.providerConfigs || [];
|
const activeProviders = bridge.providerConfigs || [];
|
||||||
const defaultProvider = activeProviders.find(p => p?.selected) || activeProviders[0];
|
const defaultProvider = activeProviders.find(p => p?.selected) || activeProviders[0];
|
||||||
const defaultProviderId = defaultProvider?.id;
|
const defaultProviderId = defaultProvider?.id;
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
@@ -1047,40 +1395,66 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
|
|
||||||
/** AI 内部执行函数 —— 自动触发和 IPC handler 共用同一套逻辑 */
|
/** AI 内部执行函数 —— 自动触发和 IPC handler 共用同一套逻辑 */
|
||||||
async function _runReportAiSummaryInternal(snapshotId, providerId) {
|
async function _runReportAiSummaryInternal(snapshotId, providerId) {
|
||||||
|
console.log("[ReportAiSummaryInternal] 🚀 开始执行 snapshotId=", snapshotId, "providerId=", providerId);
|
||||||
const snapshot = reportStore.getSnapshot(readFn, snapshotId);
|
const snapshot = reportStore.getSnapshot(readFn, snapshotId);
|
||||||
if (!snapshot) return;
|
if (!snapshot) {
|
||||||
|
console.log("[ReportAiSummaryInternal] ❌ 快照不存在:", snapshotId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[ReportAiSummaryInternal] ✅ 快照找到, devices=", snapshot.devices?.length || 0);
|
||||||
|
|
||||||
reportStore.setSnapshotAiStatus(readFn, writeFn, snapshotId, "running");
|
reportStore.setSnapshotAiStatus(readFn, writeFn, snapshotId, "running");
|
||||||
_broadcastReportAiStatus(snapshotId, "running");
|
_broadcastReportAiStatus(snapshotId, "running");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bridge = require("../aiBridge.cjs");
|
const bridge = require("../aiBridge.cjs");
|
||||||
const activeProviders = bridge.aiBridgeContext?.providerConfigs || [];
|
const activeProviders = bridge.providerConfigs;
|
||||||
|
console.log("[ReportAiSummaryInternal] activeProviders=", activeProviders.length);
|
||||||
const provider = activeProviders.find(p => p.id === providerId)
|
const provider = activeProviders.find(p => p.id === providerId)
|
||||||
|| activeProviders.find(p => p.enabled !== false);
|
|| activeProviders.find(p => p.enabled !== false);
|
||||||
if (!provider) throw new Error("AI provider 未配置");
|
if (!provider) throw new Error("AI provider 未配置");
|
||||||
const resolved = bridge.aiBridgeContext.resolveProviderApiKey(provider.id);
|
console.log("[ReportAiSummaryInternal] ✅ provider=", provider.id, provider.name);
|
||||||
|
const resolved = bridge.resolveProviderApiKey(provider.id);
|
||||||
if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
|
if (!resolved?.apiKey) throw new Error("AI provider API key 未解密");
|
||||||
|
console.log("[ReportAiSummaryInternal] ✅ API key 已解密, 长度=", resolved.apiKey.length);
|
||||||
|
|
||||||
const userContent = _buildSnapshotPrompt(snapshot);
|
const userContent = _buildSnapshotPrompt(snapshot);
|
||||||
|
// 兜底:如果压缩后仍超过 50KB,硬截断设备数量
|
||||||
|
let finalContent = userContent;
|
||||||
|
if (userContent.length > 50000) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(userContent);
|
||||||
|
if (parsed.online && parsed.online.length > 12) {
|
||||||
|
parsed.online = parsed.online.slice(0, 12);
|
||||||
|
parsed.summary.online_n = 12;
|
||||||
|
finalContent = JSON.stringify(parsed);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
const url = _buildAiUrl(provider);
|
const url = _buildAiUrl(provider);
|
||||||
const headers = { "Content-Type": "application/json", "Authorization": "Bearer __IPC_SECURED__" };
|
const headers = { "Content-Type": "application/json", "Authorization": "Bearer __IPC_SECURED__" };
|
||||||
const body = JSON.stringify(_buildAiBody(provider, userContent));
|
const body = JSON.stringify(_buildAiBody(provider, finalContent));
|
||||||
const patched = bridge.aiBridgeContext.injectApiKeyIntoRequest(url, headers, provider.id);
|
console.log("[ReportAiSummaryInternal] 准备请求 AI, url=", url?.slice(0, 80), "body.length=", body.length);
|
||||||
const skipTLS = bridge.aiBridgeContext.shouldSkipTLSVerify(provider.id);
|
const patched = bridge.injectApiKeyIntoRequest(url, headers, provider.id);
|
||||||
|
const skipTLS = bridge.shouldSkipTLSVerify(provider.id);
|
||||||
|
|
||||||
|
console.log("[ReportAiSummaryInternal] 发送 HTTP 请求...");
|
||||||
const result = await _doHttpFetch(patched.url, patched.headers, body, { skipTLS, timeoutMs: 60000 });
|
const result = await _doHttpFetch(patched.url, patched.headers, body, { skipTLS, timeoutMs: 60000 });
|
||||||
|
console.log("[ReportAiSummaryInternal] HTTP 返回 status=", result.status, "ok=", result.ok, "error=", result.error);
|
||||||
if (!result.ok) throw new Error(result.error || `HTTP ${result.status}`);
|
if (!result.ok) throw new Error(result.error || `HTTP ${result.status}`);
|
||||||
|
|
||||||
const raw = _parseAiResponse(provider, result.data);
|
const raw = _parseAiResponse(provider, result.data);
|
||||||
|
console.log("[ReportAiSummaryInternal] 解析 AI 响应长度=", raw?.length || 0);
|
||||||
const parsed = _extractJson(raw);
|
const parsed = _extractJson(raw);
|
||||||
const summary = parsed || { health_summary: raw, offline_devices: [], hidden_risks: [], recommendations: [] };
|
const summary = parsed || { health_summary: raw, offline_devices: [], hidden_risks: [], recommendations: [] };
|
||||||
|
|
||||||
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, summary);
|
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, summary);
|
||||||
_broadcastReportAiStatus(snapshotId, "done");
|
_broadcastReportAiStatus(snapshotId, "done");
|
||||||
|
console.log("[ReportAiSummaryInternal] ✅ AI 分析完成!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err?.message || String(err);
|
const msg = err?.message || String(err);
|
||||||
console.error("[runReportAiSummary] 失败:", msg);
|
console.error("[runReportAiSummary] 失败:", msg);
|
||||||
|
console.error("[runReportAiSummary] stack:", err?.stack?.split('\n').slice(0, 5).join('\n'));
|
||||||
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, null, msg);
|
reportStore.updateSnapshotAiSummary(readFn, writeFn, snapshotId, null, msg);
|
||||||
_broadcastReportAiStatus(snapshotId, "failed", msg);
|
_broadcastReportAiStatus(snapshotId, "failed", msg);
|
||||||
}
|
}
|
||||||
@@ -1141,6 +1515,189 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
return { ok };
|
return { ok };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 拓扑位置持久化(Electron 拖拽保存 → 远程浏览器可同步读取)
|
||||||
|
ipcMain.handle("inspection:saveTopologyPositions", (_e, positions) => {
|
||||||
|
if (!positions || typeof positions !== "object") return { ok: false };
|
||||||
|
try {
|
||||||
|
if (Object.keys(positions).length === 0) {
|
||||||
|
const existing = readStorage("_topology_positions");
|
||||||
|
if (existing && typeof existing === "object" && Object.keys(existing).length > 0) {
|
||||||
|
console.info("[inspection] saveTopologyPositions skip empty");
|
||||||
|
return { ok: true, skipped: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeStorage("_topology_positions", positions);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e?.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getTopologyPositions", () => {
|
||||||
|
try {
|
||||||
|
return readStorage("_topology_positions") || {};
|
||||||
|
} catch { return {}; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监控大屏标题同步(Electron localStorage → 远程浏览器可读)
|
||||||
|
ipcMain.handle("inspection:saveDashboardTitle", (_e, title) => {
|
||||||
|
if (!title || typeof title !== "string") return { ok: false };
|
||||||
|
try {
|
||||||
|
const RENDERER_DEFAULT = "云启Net智能巡检监测系统";
|
||||||
|
const existing = readStorage("_dashboard_title");
|
||||||
|
if (title === RENDERER_DEFAULT && existing && existing !== RENDERER_DEFAULT) {
|
||||||
|
console.info("[inspection] saveDashboardTitle skip default:", title, "vs:", existing);
|
||||||
|
return { ok: true, skipped: true };
|
||||||
|
}
|
||||||
|
writeStorage("_dashboard_title", title);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e?.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 产品名称同步(侧边栏显示名,远程浏览器读)
|
||||||
|
ipcMain.handle("inspection:saveProductName", (_e, name) => {
|
||||||
|
if (!name || typeof name !== "string") return { ok: false };
|
||||||
|
try {
|
||||||
|
const RENDERER_DEFAULT = "云启智能运维平台";
|
||||||
|
const existing = readStorage("_product_name");
|
||||||
|
if (name === RENDERER_DEFAULT && existing && existing !== RENDERER_DEFAULT) {
|
||||||
|
console.info("[inspection] saveProductName skip default:", name, "vs:", existing);
|
||||||
|
return { ok: true, skipped: true };
|
||||||
|
}
|
||||||
|
writeStorage("_product_name", name);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) { return { ok: false, error: e?.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getProductName", () => {
|
||||||
|
try { return readStorage("_product_name") || ""; } catch { return ""; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 报告产品名同步
|
||||||
|
ipcMain.handle("inspection:saveReportProductName", (_e, name) => {
|
||||||
|
if (typeof name !== "string") return { ok: false };
|
||||||
|
try {
|
||||||
|
if ((name === "" || name === null) && name !== undefined) {
|
||||||
|
const existing = readStorage("_report_product_name");
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
console.info("[inspection] saveReportProductName skip empty, existing:", existing);
|
||||||
|
return { ok: true, skipped: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeStorage("_report_product_name", name);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) { return { ok: false, error: e?.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getReportProductName", () => {
|
||||||
|
try { return readStorage("_report_product_name") || ""; } catch { return ""; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 完整 vault hosts 同步(前端加载后发给主进程,远程浏览器 API 读)
|
||||||
|
ipcMain.handle("inspection:saveVaultHosts", (_e, hosts) => {
|
||||||
|
if (!Array.isArray(hosts)) { console.info("[inspection] saveVaultHosts called but not array:", typeof hosts); return { ok: false }; }
|
||||||
|
try {
|
||||||
|
console.info("[inspection] saveVaultHosts called, count:", hosts.length, "sample keys:", hosts[0] ? Object.keys(hosts[0]).join(',') : 'empty');
|
||||||
|
// 守卫:renderer 首次启动 Local Storage 空时会传 [],
|
||||||
|
// 但 inspectionStorage 可能已有完整数据(含凭证)→ 不要覆盖!
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
const existing = readStorage("_vault_hosts");
|
||||||
|
if (Array.isArray(existing) && existing.length > 0) {
|
||||||
|
console.info("[inspection] saveVaultHosts 跳过空覆盖");
|
||||||
|
return { ok: true, count: existing.length, skipped: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 保存完整 hosts(含 username/password/port/identityFileId 等凭证字段)
|
||||||
|
const mapped = hosts.map(h => ({
|
||||||
|
id: h.id,
|
||||||
|
label: h.label || h.name || h.hostname || "",
|
||||||
|
hostname: h.hostname || h.name || "",
|
||||||
|
port: h.port,
|
||||||
|
username: h.username,
|
||||||
|
password: h.password,
|
||||||
|
identityFileId: h.identityFileId,
|
||||||
|
privateKey: h.privateKey,
|
||||||
|
passphrase: h.passphrase,
|
||||||
|
type: h.type || "",
|
||||||
|
distro: h.distro || h.catalogId || "",
|
||||||
|
group: h.group || "",
|
||||||
|
}));
|
||||||
|
writeStorage("_vault_hosts", mapped);
|
||||||
|
return { ok: true, count: mapped.length };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e?.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getVaultHosts", () => {
|
||||||
|
try {
|
||||||
|
return readStorage("_vault_hosts") || [];
|
||||||
|
} catch { return []; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vault 通用数据同步(renderer localStorage → 主进程 inspection-data.json → 远程 HTTP API 读)
|
||||||
|
// 覆盖所有子页面:钥匙串/代理/端口转发/脚本/笔记/已知主机/日志/分组/身份
|
||||||
|
const _VAULT_DATA_KEYS = ["hosts", "keys", "proxies", "port_forwards", "snippets", "notes", "known_hosts", "connection_logs", "groups", "identities"];
|
||||||
|
_VAULT_DATA_KEYS.forEach(vk => {
|
||||||
|
const sk = "_vault_" + vk;
|
||||||
|
// renderer → 主进程
|
||||||
|
// 守卫:renderer 首次启动 Local Storage 空时会传 [],
|
||||||
|
// 但 inspectionStorage 可能已有从开发版拷过来的完整数据 → 不要覆盖!
|
||||||
|
ipcMain.handle("inspection:syncVault:" + vk, (_e, data) => {
|
||||||
|
try {
|
||||||
|
const incomingEmpty = data === null || data === undefined
|
||||||
|
|| (Array.isArray(data) && data.length === 0)
|
||||||
|
|| (typeof data === "object" && !Array.isArray(data) && Object.keys(data).length === 0);
|
||||||
|
const existing = readStorage(sk);
|
||||||
|
const existingNonEmpty = existing !== null && existing !== undefined
|
||||||
|
&& ((Array.isArray(existing) && existing.length > 0)
|
||||||
|
|| (typeof existing === "object" && !Array.isArray(existing) && Object.keys(existing).length > 0));
|
||||||
|
if (incomingEmpty && existingNonEmpty) {
|
||||||
|
console.info(`[inspection] syncVault:${vk} 跳过:renderer 传空,但 inspectionStorage 已有 ${(Array.isArray(existing) ? existing.length : Object.keys(existing).length)} 条`);
|
||||||
|
return { ok: true, skipped: true };
|
||||||
|
}
|
||||||
|
writeStorage(sk, data ?? (Array.isArray(data) ? [] : {}));
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e?.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 主进程内部读取(remoteHttpServer 也会直接 require inspectionStorage 读)
|
||||||
|
ipcMain.handle("inspection:getVault:" + vk, () => {
|
||||||
|
try { return readStorage(sk); } catch { return null; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 拓扑连线同步(Electron localStorage → 主进程 → 远程浏览器 API 读)
|
||||||
|
ipcMain.handle("inspection:getTopologyLinks", () => {
|
||||||
|
try { const v = readStorage("_topology_links"); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:saveTopologyLinks", (_e, links) => {
|
||||||
|
try {
|
||||||
|
const arr = Array.isArray(links) ? links : [];
|
||||||
|
if (arr.length === 0) {
|
||||||
|
const existing = readStorage("_topology_links");
|
||||||
|
if (Array.isArray(existing) && existing.length > 0) {
|
||||||
|
console.info("[inspection] saveTopologyLinks skip empty");
|
||||||
|
return { ok: true, count: existing.length, skipped: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeStorage("_topology_links", arr);
|
||||||
|
return { ok: true, count: arr.length };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e?.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getDashboardTitle", () => {
|
||||||
|
try {
|
||||||
|
return readStorage("_dashboard_title") || "";
|
||||||
|
} catch { return ""; }
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("inspection:clearReportAiSummary", (_e, { snapshotId }) => {
|
ipcMain.handle("inspection:clearReportAiSummary", (_e, { snapshotId }) => {
|
||||||
if (!snapshotId) return { ok: false, error: "snapshotId required" };
|
if (!snapshotId) return { ok: false, error: "snapshotId required" };
|
||||||
const ok = reportStore.clearSnapshotAiSummary(readFn, writeFn, snapshotId);
|
const ok = reportStore.clearSnapshotAiSummary(readFn, writeFn, snapshotId);
|
||||||
@@ -1150,8 +1707,12 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
ipcMain.handle("inspection:runReportAiSummary", async (_e, { snapshotId, providerId }) => {
|
ipcMain.handle("inspection:runReportAiSummary", async (_e, { snapshotId, providerId }) => {
|
||||||
if (!snapshotId) return { ok: false, error: "snapshotId required" };
|
if (!snapshotId) return { ok: false, error: "snapshotId required" };
|
||||||
|
|
||||||
|
console.log("[ReportAiSummary] ⚡ 收到 IPC 请求 snapshotId=", snapshotId, "providerId=", providerId);
|
||||||
|
console.log("[ReportAiSummary] providerConfigs=", require("../aiBridge.cjs").aiBridgeContext?.providerConfigs?.length || 0, "个");
|
||||||
|
|
||||||
// 后台独立执行 —— 不阻塞 IPC 返回
|
// 后台独立执行 —— 不阻塞 IPC 返回
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
|
console.log("[ReportAiSummary] ⏱️ setImmediate 触发执行...");
|
||||||
_runReportAiSummaryInternal(snapshotId, providerId)
|
_runReportAiSummaryInternal(snapshotId, providerId)
|
||||||
.catch(err => console.error("[runReportAiSummary] 异常:", err?.message || String(err)));
|
.catch(err => console.error("[runReportAiSummary] 异常:", err?.message || String(err)));
|
||||||
});
|
});
|
||||||
@@ -1360,6 +1921,7 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
|
|
||||||
// 存储前端传入的设备 SSH 配置(hostId → sshConfig)
|
// 存储前端传入的设备 SSH 配置(hostId → sshConfig)
|
||||||
let _hostConfigs = null;
|
let _hostConfigs = null;
|
||||||
|
let _loggedHostCreds = false;
|
||||||
|
|
||||||
// ---------- 调度器控制 ----------
|
// ---------- 调度器控制 ----------
|
||||||
ipcMain.handle("inspection:scheduler:start", (_e, options = {}) => {
|
ipcMain.handle("inspection:scheduler:start", (_e, options = {}) => {
|
||||||
@@ -1379,21 +1941,35 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 持久化 hostInfo 到 inspection-data.json,供远程 HTTP Server 读取补全设备名
|
||||||
|
try {
|
||||||
|
writeFn("_host_info_cache", hostInfo);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
if (!_scheduler) {
|
if (!_scheduler) {
|
||||||
_scheduler = new InspectionScheduler({
|
_scheduler = new InspectionScheduler({
|
||||||
readFn,
|
readFn,
|
||||||
writeFn,
|
writeFn,
|
||||||
execFn: async (hostId, command) => {
|
execFn: async (hostId, command) => {
|
||||||
// 优先用前端传入的 hostConfigs
|
|
||||||
if (_hostConfigs && _hostConfigs[hostId]) {
|
if (_hostConfigs && _hostConfigs[hostId]) {
|
||||||
const config = _hostConfigs[hostId];
|
const config = _hostConfigs[hostId];
|
||||||
|
if (!_loggedHostCreds) {
|
||||||
|
console.log("[Inspection] hostConfigs 凭据快照:", JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(_hostConfigs).map(([id, c]) => [id, {
|
||||||
|
host: c.host,
|
||||||
|
username: c.username,
|
||||||
|
hasPassword: !!c.password,
|
||||||
|
hasPrivateKey: !!c.privateKey,
|
||||||
|
}]))
|
||||||
|
));
|
||||||
|
_loggedHostCreds = true;
|
||||||
|
}
|
||||||
const { execSshCommand: execCmd } = require("./inspectionExec.cjs");
|
const { execSshCommand: execCmd } = require("./inspectionExec.cjs");
|
||||||
return await execCmd(config, command, {
|
return await execCmd(config, command, {
|
||||||
connectTimeoutMs: 20000,
|
connectTimeoutMs: 20000,
|
||||||
execTimeoutMs: 30000,
|
execTimeoutMs: 30000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 兜底:尝试复用已有终端会话
|
|
||||||
const session = findSessionByHost(hostId);
|
const session = findSessionByHost(hostId);
|
||||||
if (session?.conn) {
|
if (session?.conn) {
|
||||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||||
@@ -1403,14 +1979,56 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
}
|
}
|
||||||
throw new Error(`无法执行巡检:未找到主机 ${hostId} 的 SSH 配置`);
|
throw new Error(`无法执行巡检:未找到主机 ${hostId} 的 SSH 配置`);
|
||||||
},
|
},
|
||||||
|
// 批量执行多条命令(一次连接执行多条,华为/H3C 交换机采集器需要)
|
||||||
|
// 闭包和 execFn 一样——动态读 _hostConfigs 最新值
|
||||||
|
execCommandsFn: async (hostId, commands, options = {}) => {
|
||||||
|
if (_hostConfigs && _hostConfigs[hostId]) {
|
||||||
|
const config = _hostConfigs[hostId];
|
||||||
|
if (!_loggedHostCreds) {
|
||||||
|
console.log("[Inspection] execCommandsFn 凭据快照:", JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(_hostConfigs).map(([id, c]) => [id, {
|
||||||
|
host: c.host,
|
||||||
|
username: c.username,
|
||||||
|
hasPassword: !!c.password,
|
||||||
|
hasPrivateKey: !!c.privateKey,
|
||||||
|
}]))
|
||||||
|
));
|
||||||
|
_loggedHostCreds = true;
|
||||||
|
}
|
||||||
|
const { execSshCommands } = require("./inspectionExec.cjs");
|
||||||
|
return await execSshCommands(config, commands, {
|
||||||
|
connectTimeoutMs: 20000,
|
||||||
|
execTimeoutMs: options.execTimeoutMs || 30000,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`无法批量执行巡检:未找到主机 ${hostId} 的 SSH 配置`);
|
||||||
|
},
|
||||||
pollIntervalMs: 30 * 1000,
|
pollIntervalMs: 30 * 1000,
|
||||||
concurrency: 5,
|
concurrency: 5,
|
||||||
timeoutMs: 30 * 1000,
|
timeoutMs: 30 * 1000,
|
||||||
hostInfo,
|
hostInfo,
|
||||||
onAlertChanged: broadcastAlertChanged,
|
onAlertChanged: broadcastAlertChanged,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// scheduler 已存在:更新 hostInfo(告警显示 device_name 用)
|
||||||
|
_scheduler._hostInfo = hostInfo;
|
||||||
|
console.log("[Inspection] 更新 hostConfigs:", Object.keys(_hostConfigs || {}).length, "台设备");
|
||||||
}
|
}
|
||||||
|
// start() 幂等:已 running 时直接返回;这里调 scanAllNow 让新 hostConfigs 立即生效,
|
||||||
|
// 不用等下一个 30 秒 tick
|
||||||
|
const wasRunning = _scheduler.isRunning();
|
||||||
_scheduler.start();
|
_scheduler.start();
|
||||||
|
if (wasRunning && Object.keys(_hostConfigs || {}).length > 0) {
|
||||||
|
// 已在跑 → 立即触发一次 scanAllNow,把新 hostConfigs 对应的设备加入队列
|
||||||
|
setImmediate(() => {
|
||||||
|
try {
|
||||||
|
_scheduler.scanAllNow();
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Inspection] 触发立即巡检失败:", e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
return _scheduler.getStatus();
|
return _scheduler.getStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1426,6 +2044,23 @@ function registerInspectionHandlers(ipcMain, options) {
|
|||||||
return _scheduler ? _scheduler.getStatus() : { running: false };
|
return _scheduler ? _scheduler.getStatus() : { running: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── 全局自动巡检一键启用/停止 ───
|
||||||
|
ipcMain.handle("inspection:scheduler:getAutoEnabled", () => {
|
||||||
|
const raw = readFn("inspection-auto-enabled");
|
||||||
|
return raw !== "false"; // 默认 true
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:scheduler:setAutoEnabled", (_e, enabled) => {
|
||||||
|
const value = !!enabled;
|
||||||
|
writeFn("inspection-auto-enabled", value ? "true" : "false");
|
||||||
|
console.log(`[InspectionScheduler] 全局自动巡检 ${value ? "已启用" : "已停止"}`);
|
||||||
|
if (!value && _scheduler) {
|
||||||
|
// 停止时清空队列,避免积压
|
||||||
|
_scheduler._scanQueue = [];
|
||||||
|
}
|
||||||
|
return { enabled: value };
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("inspection:scheduler:scanNow", (_e, hostId) => {
|
ipcMain.handle("inspection:scheduler:scanNow", (_e, hostId) => {
|
||||||
if (!_scheduler) return { error: "调度器未启动" };
|
if (!_scheduler) return { error: "调度器未启动" };
|
||||||
return _scheduler.scanDeviceNow(hostId);
|
return _scheduler.scanDeviceNow(hostId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 巡检结果存储
|
* 巡检结果存储
|
||||||
@@ -22,8 +22,10 @@ function _ensureLoaded(readFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _safeParse(text, def) {
|
function _safeParse(text, def) {
|
||||||
|
if (text === null || text === undefined) return def;
|
||||||
|
if (typeof text === "object") return text;
|
||||||
try { return JSON.parse(text); }
|
try { return JSON.parse(text); }
|
||||||
catch (e) { return def; }
|
catch { return def; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function _save(writeFn) {
|
function _save(writeFn) {
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ class InspectionScheduler {
|
|||||||
* @param {function} options.readFn - 读取存储 (key) => string
|
* @param {function} options.readFn - 读取存储 (key) => string
|
||||||
* @param {function} options.writeFn - 写入存储 (key, value) => void
|
* @param {function} options.writeFn - 写入存储 (key, value) => void
|
||||||
* @param {function} options.getConnectionFn - 获取 SSH 连接 (hostId) => connection 或 null
|
* @param {function} options.getConnectionFn - 获取 SSH 连接 (hostId) => connection 或 null
|
||||||
* @param {function} options.execFn - 执行命令 (hostId, command) => Promise<string>
|
* @param {function} options.execFn - 执行单条命令 (hostId, command) => Promise<string>
|
||||||
|
* @param {function} [options.execCommandsFn] - 批量执行多条命令 (hostId, commands) => Promise<{ results, stdout }>
|
||||||
* @param {function} [options.onResult] - 单设备巡检完成回调
|
* @param {function} [options.onResult] - 单设备巡检完成回调
|
||||||
* @param {number} [options.pollIntervalMs] - 轮询间隔
|
* @param {number} [options.pollIntervalMs] - 轮询间隔
|
||||||
* @param {number} [options.concurrency] - 并发数
|
* @param {number} [options.concurrency] - 并发数
|
||||||
@@ -44,6 +45,7 @@ class InspectionScheduler {
|
|||||||
this.writeFn = options.writeFn;
|
this.writeFn = options.writeFn;
|
||||||
this.getConnectionFn = options.getConnectionFn;
|
this.getConnectionFn = options.getConnectionFn;
|
||||||
this.execFn = options.execFn;
|
this.execFn = options.execFn;
|
||||||
|
this.execCommandsFn = options.execCommandsFn;
|
||||||
this.onResult = options.onResult || (() => {});
|
this.onResult = options.onResult || (() => {});
|
||||||
this.onAlertChanged = options.onAlertChanged || (() => {});
|
this.onAlertChanged = options.onAlertChanged || (() => {});
|
||||||
this.pollIntervalMs = options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
|
this.pollIntervalMs = options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
|
||||||
@@ -93,6 +95,13 @@ class InspectionScheduler {
|
|||||||
if (!this._running) return;
|
if (!this._running) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 全局自动巡检开关(一键启用/停止)
|
||||||
|
const autoEnabledRaw = this.readFn("inspection-auto-enabled");
|
||||||
|
const autoEnabled = autoEnabledRaw !== "false"; // 默认 true
|
||||||
|
if (!autoEnabled) {
|
||||||
|
return; // 全局停止 —— 跳过本次 tick
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 获取所有启用巡检的设备
|
// 1. 获取所有启用巡检的设备
|
||||||
const devices = deviceStore.getEnabledDevices(this.readFn);
|
const devices = deviceStore.getEnabledDevices(this.readFn);
|
||||||
|
|
||||||
@@ -149,6 +158,8 @@ class InspectionScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 创建采集器
|
// 2. 创建采集器
|
||||||
|
// connectionInfo 只传 { host: hostId } 作为标识——adapter 的 execCommands 会被 options.execCommands 覆盖,
|
||||||
|
// 真正的 SSH 凭据由主进程 execCommandsFn 从 _hostConfigs 读取(和 execFn 同理)
|
||||||
const adapter = adapterRegistry.createAdapter(
|
const adapter = adapterRegistry.createAdapter(
|
||||||
catalog.adapter_class,
|
catalog.adapter_class,
|
||||||
{ host: hostId },
|
{ host: hostId },
|
||||||
@@ -157,6 +168,11 @@ class InspectionScheduler {
|
|||||||
if (!this.execFn) throw new Error("execFn 未配置");
|
if (!this.execFn) throw new Error("execFn 未配置");
|
||||||
return this.execFn(hostId, cmd);
|
return this.execFn(hostId, cmd);
|
||||||
},
|
},
|
||||||
|
execCommands: this.execCommandsFn
|
||||||
|
? async (commands, options) => {
|
||||||
|
return this.execCommandsFn(hostId, commands, options);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
timeoutMs: this.timeoutMs,
|
timeoutMs: this.timeoutMs,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -281,10 +297,8 @@ class InspectionScheduler {
|
|||||||
console.log(`[InspectionScheduler] 告警评估 device=${deviceName}(${deviceExt.host_id}) online=${metrics.online} 规则数=${rules.length}`);
|
console.log(`[InspectionScheduler] 告警评估 device=${deviceName}(${deviceExt.host_id}) online=${metrics.online} 规则数=${rules.length}`);
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
// 特殊处理:online 指标
|
|
||||||
if (rule.metric === "online") {
|
if (rule.metric === "online") {
|
||||||
const isOnline = metrics.online !== false;
|
const isOnline = metrics.online !== false;
|
||||||
// threshold 为 "false" 表示离线时告警
|
|
||||||
const shouldAlert = rule.threshold === "false" ? !isOnline : isOnline;
|
const shouldAlert = rule.threshold === "false" ? !isOnline : isOnline;
|
||||||
console.log(`[InspectionScheduler] 离线规则: name=${rule.name} enabled=${rule.enabled} threshold=${rule.threshold} isOnline=${isOnline} shouldAlert=${shouldAlert}`);
|
console.log(`[InspectionScheduler] 离线规则: name=${rule.name} enabled=${rule.enabled} threshold=${rule.threshold} isOnline=${isOnline} shouldAlert=${shouldAlert}`);
|
||||||
const alertResult = alertRecordStore.processAlert(
|
const alertResult = alertRecordStore.processAlert(
|
||||||
@@ -308,8 +322,8 @@ class InspectionScheduler {
|
|||||||
|
|
||||||
const isTriggered = alertRuleStore.evaluateRule(value, rule.operator, rule.threshold);
|
const isTriggered = alertRuleStore.evaluateRule(value, rule.operator, rule.threshold);
|
||||||
|
|
||||||
const hostInfo = this._hostInfo[deviceExt.host_id] || {};
|
// 注意:hostInfo / deviceName 在函数顶部 L285-286 已声明,此处直接复用,
|
||||||
const deviceName = hostInfo.label || hostInfo.hostname || deviceExt.host_id;
|
// 避免同名 const 重复声明导致 TDZ(Cannot access 'deviceName' before initialization)
|
||||||
const alertResult = alertRecordStore.processAlert(
|
const alertResult = alertRecordStore.processAlert(
|
||||||
this.readFn,
|
this.readFn,
|
||||||
this.writeFn,
|
this.writeFn,
|
||||||
|
|||||||
@@ -81,9 +81,17 @@ function flushSync() {
|
|||||||
_flushToDisk();
|
_flushToDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetCache() {
|
||||||
|
// 先确保内存中的修改已写到磁盘(200ms 异步保存可能还没触发)
|
||||||
|
_flushToDisk();
|
||||||
|
_storageCache = null;
|
||||||
|
_loadFromDisk();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init,
|
init,
|
||||||
readStorage,
|
readStorage,
|
||||||
writeStorage,
|
writeStorage,
|
||||||
flushSync,
|
flushSync,
|
||||||
|
resetCache,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 巡检报告调度配置存储
|
* 巡检报告调度配置存储
|
||||||
@@ -26,6 +26,8 @@ const DEFAULTS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function _safeParse(raw, def) {
|
function _safeParse(raw, def) {
|
||||||
|
if (raw === null || raw === undefined) return def;
|
||||||
|
if (typeof raw === "object") return raw;
|
||||||
try { return JSON.parse(raw); }
|
try { return JSON.parse(raw); }
|
||||||
catch { return def; }
|
catch { return def; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ function start({ readStorage, writeStorage, runSnapshotFn, onDailyReportReady, g
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 启动后等 60s tick 自动处理第一次(不立即触发,避免重启爆发)
|
// 启动后等 60s tick 自动处理第一次(不立即触发,避免重启爆发)
|
||||||
console.log("[reportScheduler] 已启动,间隔", (cfg.defaultIntervalHours || 1), "h,下次 tick 检查在 60s 后");
|
const curCfg = reportScheduleStore.getSchedule(readFn);
|
||||||
|
console.log("[reportScheduler] 已启动,间隔", (curCfg.defaultIntervalHours || 1), "h,下次 tick 检查在 60s 后");
|
||||||
|
|
||||||
// 每 60 秒 tick 一次
|
// 每 60 秒 tick 一次
|
||||||
_tickTimer = setInterval(() => { tick().catch(e => console.error("[reportScheduler] tick 异常:", e)); }, 60 * 1000);
|
_tickTimer = setInterval(() => { tick().catch(e => console.error("[reportScheduler] tick 异常:", e)); }, 60 * 1000);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 巡检报告快照存储
|
* 巡检报告快照存储
|
||||||
@@ -35,8 +35,10 @@ const MAX_CMD_STDOUT = 4000; // 单条命令 stdout 保留前 4000 字符
|
|||||||
let _snapshots = null;
|
let _snapshots = null;
|
||||||
|
|
||||||
function _safeParse(text, def) {
|
function _safeParse(text, def) {
|
||||||
|
if (text === null || text === undefined) return def;
|
||||||
|
if (typeof text === "object") return text;
|
||||||
try { return JSON.parse(text); }
|
try { return JSON.parse(text); }
|
||||||
catch (e) { return def; }
|
catch { return def; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function _ensureLoaded(readFn) {
|
function _ensureLoaded(readFn) {
|
||||||
@@ -215,6 +217,8 @@ function clearSnapshotAiSummary(readFn, writeFn, snapshotId) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _resetForTest() { _snapshots = null; }
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
MAX_SNAPSHOTS,
|
MAX_SNAPSHOTS,
|
||||||
MAX_CMD_STDOUT,
|
MAX_CMD_STDOUT,
|
||||||
@@ -227,4 +231,5 @@ module.exports = {
|
|||||||
setSnapshotAiStatus,
|
setSnapshotAiStatus,
|
||||||
updateSnapshotAiSummary,
|
updateSnapshotAiSummary,
|
||||||
clearSnapshotAiSummary,
|
clearSnapshotAiSummary,
|
||||||
|
_resetForTest,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -268,10 +268,10 @@ const portableData = applyPortableDataDirectory({ app });
|
|||||||
if (portableData) {
|
if (portableData) {
|
||||||
console.info(`[Main] Portable data directory: ${portableData.dataDirectory}`);
|
console.info(`[Main] Portable data directory: ${portableData.dataDirectory}`);
|
||||||
}
|
}
|
||||||
|
const appPath = path.join(__dirname, "..");
|
||||||
const preload = path.join(__dirname, "preload.cjs");
|
const preload = path.join(__dirname, "preload.cjs");
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
const appIconManager = require("./bridges/appIconManager.cjs");
|
const appIconManager = require("./bridges/appIconManager.cjs");
|
||||||
const appPath = path.join(__dirname, "..");
|
|
||||||
appIconManager.initializeAppIconManager(appPath, {
|
appIconManager.initializeAppIconManager(appPath, {
|
||||||
preferPublic: !app.isPackaged,
|
preferPublic: !app.isPackaged,
|
||||||
isMac,
|
isMac,
|
||||||
@@ -1313,6 +1313,7 @@ if (!gotLock) {
|
|||||||
// Application lifecycle
|
// Application lifecycle
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
registerAppProtocol();
|
registerAppProtocol();
|
||||||
|
|
||||||
const initialSshDeepLinkPreference = applyInitialSshDeepLinkPreference({
|
const initialSshDeepLinkPreference = applyInitialSshDeepLinkPreference({
|
||||||
enabled: sshDeepLinkEnabled,
|
enabled: sshDeepLinkEnabled,
|
||||||
applyPreference: (enabled) => applySshProtocolClientPreference({ app, enabled, isDev }),
|
applyPreference: (enabled) => applySshProtocolClientPreference({ app, enabled, isDev }),
|
||||||
|
|||||||
@@ -1196,6 +1196,21 @@ function createBridgeRegistrar(context) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Main] All bridges registered successfully');
|
console.log('[Main] All bridges registered successfully');
|
||||||
|
|
||||||
|
// ─── 远程 HTTP Server(单机版浏览器访问) ───
|
||||||
|
try {
|
||||||
|
const remoteHttp = require("../remoteHttpServer.cjs");
|
||||||
|
// 复用已初始化的 inspectionStorage,共享同一个模块级缓存
|
||||||
|
// 这样 Electron 本地改配置后,远程 HTTP API 立即读到最新值
|
||||||
|
ipcMain.handle("remoteHttp:getStatus", () => remoteHttp.getStatus());
|
||||||
|
ipcMain.handle("remoteHttp:configure", (_e, enabled, port) => remoteHttp.configure(enabled, port));
|
||||||
|
// 自动启动(打包版才有意义,开发版也启方便调试)
|
||||||
|
remoteHttp.start({ storage: inspectionStorage }).catch((e) => {
|
||||||
|
console.warn("[RemoteHTTP] auto-start failed:", e.message);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[RemoteHTTP] 远程 HTTP Server 初始化失败:", e.message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return registerBridges;
|
return registerBridges;
|
||||||
|
|||||||
@@ -6,25 +6,30 @@ function applyPortableDataDirectory({
|
|||||||
env = process.env,
|
env = process.env,
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
}) {
|
}) {
|
||||||
if (platform !== "win32" || !app?.isPackaged) return null;
|
// 触发条件:exe 所在目录下有 .portable 标记文件
|
||||||
|
// 这样开发版(没有 .portable)完全不受影响,只有绿色便携版才生效
|
||||||
|
if (platform !== "win32") return null;
|
||||||
|
|
||||||
const portableLauncherDirectory = typeof env?.PORTABLE_EXECUTABLE_DIR === "string"
|
let exeDir;
|
||||||
? env.PORTABLE_EXECUTABLE_DIR.trim()
|
try { exeDir = path.dirname(app.getPath("exe")); } catch { return null; }
|
||||||
: "";
|
|
||||||
const baseDirectory = portableLauncherDirectory || path.dirname(app.getPath("exe"));
|
const markerFile = path.join(exeDir, ".portable");
|
||||||
const dataDirectory = path.join(baseDirectory, "data");
|
if (!fs.existsSync(markerFile)) return null;
|
||||||
if (!fs.existsSync(dataDirectory) || !fs.statSync(dataDirectory).isDirectory()) {
|
|
||||||
|
const dataDirectory = path.join(exeDir, "data");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dataDirectory)) {
|
||||||
|
fs.mkdirSync(dataDirectory, { recursive: true });
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.info(`[Main] Portable data directory (via .portable marker): ${dataDirectory}`);
|
||||||
app.setPath("userData", dataDirectory);
|
app.setPath("userData", dataDirectory);
|
||||||
app.setPath("sessionData", dataDirectory);
|
app.setPath("sessionData", dataDirectory);
|
||||||
return {
|
return { dataDirectory, source: "marker-file" };
|
||||||
dataDirectory,
|
|
||||||
source: portableLauncherDirectory
|
|
||||||
? "portable-launcher-directory"
|
|
||||||
: "executable-directory",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { applyPortableDataDirectory };
|
module.exports = { applyPortableDataDirectory };
|
||||||
|
|||||||
@@ -2081,6 +2081,7 @@ function createPreloadApi(ctx) {
|
|||||||
getAllExtensions: () => ipcRenderer.invoke("inspection:getAllExtensions"),
|
getAllExtensions: () => ipcRenderer.invoke("inspection:getAllExtensions"),
|
||||||
getExtension: (hostId) => ipcRenderer.invoke("inspection:getExtension", hostId),
|
getExtension: (hostId) => ipcRenderer.invoke("inspection:getExtension", hostId),
|
||||||
upsertExtension: (hostId, data) => ipcRenderer.invoke("inspection:upsertExtension", hostId, data),
|
upsertExtension: (hostId, data) => ipcRenderer.invoke("inspection:upsertExtension", hostId, data),
|
||||||
|
batchSetInspectionEnabled: (enabled) => ipcRenderer.invoke("inspection:batchSetInspectionEnabled", enabled),
|
||||||
deleteExtension: (hostId) => ipcRenderer.invoke("inspection:deleteExtension", hostId),
|
deleteExtension: (hostId) => ipcRenderer.invoke("inspection:deleteExtension", hostId),
|
||||||
getEffectiveInterval: (hostId) => ipcRenderer.invoke("inspection:getEffectiveInterval", hostId),
|
getEffectiveInterval: (hostId) => ipcRenderer.invoke("inspection:getEffectiveInterval", hostId),
|
||||||
getEnabledDevices: () => ipcRenderer.invoke("inspection:getEnabledDevices"),
|
getEnabledDevices: () => ipcRenderer.invoke("inspection:getEnabledDevices"),
|
||||||
@@ -2105,6 +2106,8 @@ function createPreloadApi(ctx) {
|
|||||||
schedulerStatus: () => ipcRenderer.invoke("inspection:scheduler:status"),
|
schedulerStatus: () => ipcRenderer.invoke("inspection:scheduler:status"),
|
||||||
scanNow: (hostId) => ipcRenderer.invoke("inspection:scheduler:scanNow", hostId),
|
scanNow: (hostId) => ipcRenderer.invoke("inspection:scheduler:scanNow", hostId),
|
||||||
scanAllNow: () => ipcRenderer.invoke("inspection:scheduler:scanAllNow"),
|
scanAllNow: () => ipcRenderer.invoke("inspection:scheduler:scanAllNow"),
|
||||||
|
getAutoEnabled: () => ipcRenderer.invoke("inspection:scheduler:getAutoEnabled"),
|
||||||
|
setAutoEnabled: (enabled) => ipcRenderer.invoke("inspection:scheduler:setAutoEnabled", enabled),
|
||||||
runInspection: (payload) => ipcRenderer.invoke("inspection:runInspection", payload),
|
runInspection: (payload) => ipcRenderer.invoke("inspection:runInspection", payload),
|
||||||
runSingleCommand: (payload) => ipcRenderer.invoke("inspection:runSingleCommand", payload),
|
runSingleCommand: (payload) => ipcRenderer.invoke("inspection:runSingleCommand", payload),
|
||||||
// 巡检报告快照
|
// 巡检报告快照
|
||||||
@@ -2116,6 +2119,23 @@ function createPreloadApi(ctx) {
|
|||||||
saveReportAiSummary: (snapshotId, aiSummary) => ipcRenderer.invoke("inspection:saveReportAiSummary", { snapshotId, aiSummary }),
|
saveReportAiSummary: (snapshotId, aiSummary) => ipcRenderer.invoke("inspection:saveReportAiSummary", { snapshotId, aiSummary }),
|
||||||
clearReportAiSummary: (snapshotId) => ipcRenderer.invoke("inspection:clearReportAiSummary", { snapshotId }),
|
clearReportAiSummary: (snapshotId) => ipcRenderer.invoke("inspection:clearReportAiSummary", { snapshotId }),
|
||||||
runReportAiSummary: (snapshotId, providerId) => ipcRenderer.invoke("inspection:runReportAiSummary", { snapshotId, providerId }),
|
runReportAiSummary: (snapshotId, providerId) => ipcRenderer.invoke("inspection:runReportAiSummary", { snapshotId, providerId }),
|
||||||
|
// 拓扑位置持久化(供远程浏览器同步读取)
|
||||||
|
saveTopologyPositions: (positions) => ipcRenderer.invoke("inspection:saveTopologyPositions", positions),
|
||||||
|
getTopologyPositions: () => ipcRenderer.invoke("inspection:getTopologyPositions"),
|
||||||
|
// 监控大屏标题同步
|
||||||
|
saveDashboardTitle: (title) => ipcRenderer.invoke("inspection:saveDashboardTitle", title),
|
||||||
|
getDashboardTitle: () => ipcRenderer.invoke("inspection:getDashboardTitle"),
|
||||||
|
saveProductName: (name) => ipcRenderer.invoke("inspection:saveProductName", name),
|
||||||
|
getProductName: () => ipcRenderer.invoke("inspection:getProductName"),
|
||||||
|
saveReportProductName: (name) => ipcRenderer.invoke("inspection:saveReportProductName", name),
|
||||||
|
getReportProductName: () => ipcRenderer.invoke("inspection:getReportProductName"),
|
||||||
|
saveVaultHosts: (hosts) => ipcRenderer.invoke("inspection:saveVaultHosts", hosts),
|
||||||
|
getVaultHosts: () => ipcRenderer.invoke("inspection:getVaultHosts"),
|
||||||
|
// Vault 通用数据同步(keys/proxies/port_forwards/snippets/notes/known_hosts/connection_logs/groups/identities)
|
||||||
|
syncVaultData: (key, data) => ipcRenderer.invoke("inspection:syncVault:" + key, data),
|
||||||
|
getVaultData: (key) => ipcRenderer.invoke("inspection:getVault:" + key),
|
||||||
|
saveTopologyLinks: (links) => ipcRenderer.invoke("inspection:saveTopologyLinks", links),
|
||||||
|
getTopologyLinks: () => ipcRenderer.invoke("inspection:getTopologyLinks"),
|
||||||
// 巡检报告调度配置
|
// 巡检报告调度配置
|
||||||
getReportSchedule: () => ipcRenderer.invoke("inspection:getReportSchedule"),
|
getReportSchedule: () => ipcRenderer.invoke("inspection:getReportSchedule"),
|
||||||
saveReportSchedule: (config) => ipcRenderer.invoke("inspection:saveReportSchedule", config),
|
saveReportSchedule: (config) => ipcRenderer.invoke("inspection:saveReportSchedule", config),
|
||||||
@@ -2143,6 +2163,12 @@ function createPreloadApi(ctx) {
|
|||||||
return () => ipcRenderer.removeListener("inspection:ruleChanged", handler);
|
return () => ipcRenderer.removeListener("inspection:ruleChanged", handler);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── 远程 HTTP Server(浏览器访问单机版) ───
|
||||||
|
remoteHttp: {
|
||||||
|
getStatus: () => ipcRenderer.invoke("remoteHttp:getStatus"),
|
||||||
|
configure: (enabled, port) => ipcRenderer.invoke("remoteHttp:configure", enabled, port),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1082
electron/remoteHttpServer.cjs
Normal file
1082
electron/remoteHttpServer.cjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -130,16 +130,137 @@ export interface SchedulerStatus {
|
|||||||
|
|
||||||
const api = (window as any).netcatty?.inspection;
|
const api = (window as any).netcatty?.inspection;
|
||||||
|
|
||||||
|
// 浏览器远程访问环境:通过 HTTP API fallback
|
||||||
|
const IS_REMOTE_BROWSER = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||||
|
const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || "";
|
||||||
|
|
||||||
|
const REMOTE_API_MAP: Record<string, { method: string; path: string; arrayField?: string }> = {
|
||||||
|
getLatestAll: { method: "GET", path: "/api/inspection/results", arrayField: "results" },
|
||||||
|
getLatestByDevice: { method: "GET", path: "/api/inspection/results", arrayField: "results" },
|
||||||
|
getRecentResults: { method: "GET", path: "/api/inspection/results/recent", arrayField: "results" },
|
||||||
|
getActiveAlerts: { method: "GET", path: "/api/alerts", arrayField: "alerts" },
|
||||||
|
listAlerts: { method: "GET", path: "/api/alerts", arrayField: "alerts" },
|
||||||
|
getAlertStats: { method: "GET", path: "/api/alert-stats" },
|
||||||
|
listRules: { method: "GET", path: "/api/alert-rules", arrayField: "rules" },
|
||||||
|
getRule: { method: "GET", path: "/api/alert-rules", arrayField: "rules" },
|
||||||
|
getAllExtensions: { method: "GET", path: "/api/devices", arrayField: "devices" },
|
||||||
|
getEnabledDevices: { method: "GET", path: "/api/devices/enabled", arrayField: "devices" },
|
||||||
|
schedulerStatus: { method: "GET", path: "/api/inspection/scheduler-status" },
|
||||||
|
getCatalog: { method: "GET", path: "/api/inspection/catalog", arrayField: "catalog" },
|
||||||
|
getSysConfig: { method: "GET", path: "/api/inspection/sys-config" },
|
||||||
|
getLabels: { method: "GET", path: "/api/inspection/labels" },
|
||||||
|
listReportSnapshots: { method: "GET", path: "/api/report/snapshots", arrayField: "snapshots" },
|
||||||
|
isReportSnapshotRunning: { method: "GET", path: "/api/report/snapshot-status" },
|
||||||
|
getReportSchedule: { method: "GET", path: "/api/report/schedule" },
|
||||||
|
listDailyReports: { method: "GET", path: "/api/daily-reports", arrayField: "reports" },
|
||||||
|
batchSetInspectionEnabled: { method: "POST", path: "/api/devices/batch-enable" },
|
||||||
|
runReportAiSummary: { method: "POST", path: "/api/run-report-ai-summary" },
|
||||||
|
generateDailyReport: { method: "POST", path: "/api/generate-daily-report" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 某些 API 返回数组 —— 在 REMOTE_API_MAP 里用 arrayField 标记
|
||||||
|
// 找不到映射的 API 默认返回 null,让调用方自己兜底
|
||||||
|
const ARRAY_METHODS = new Set([
|
||||||
|
"listReportSnapshots", "listDailyReports", "listRules", "getLatestAll",
|
||||||
|
"getLatestByDevice", "getRecentResults", "getActiveAlerts", "listAlerts",
|
||||||
|
"getAllExtensions", "getEnabledDevices", "getCatalog",
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function remoteFetch(name: string, ...args: any[]): Promise<any> {
|
||||||
|
const mapping = REMOTE_API_MAP[name];
|
||||||
|
if (!mapping) {
|
||||||
|
// 没有映射的方法:读场景返回空数组/空对象兜底,避免 .find 崩溃
|
||||||
|
console.warn(`[Inspection] remote fallback: no mapping for ${name}, returning safe default`);
|
||||||
|
if (ARRAY_METHODS.has(name)) return [];
|
||||||
|
if (name === "getSchedule" || name === "getReportSchedule") return {};
|
||||||
|
if (name === "getSysConfig") return {};
|
||||||
|
if (name === "getLabels") return { alert_levels: {}, device_tiers: {}, inspection_status: {}, alert_record_status: {}, operators: {}, device_catalog: {}, metrics: {} };
|
||||||
|
if (name === "schedulerStatus" || name === "isReportSnapshotRunning") return { running: false };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let url = `${mapping.path}?token=${encodeURIComponent(REMOTE_TOKEN)}`;
|
||||||
|
if (name === "getLatestByDevice" && args[0]) {
|
||||||
|
url += `&device_id=${encodeURIComponent(args[0])}`;
|
||||||
|
}
|
||||||
|
if ((name === "listAlerts" || name === "listReportSnapshots") && args[0] !== undefined) {
|
||||||
|
const opts = typeof args[0] === "object" ? args[0] : { limit: args[0] };
|
||||||
|
if (opts.status) url += `&status=${opts.status}`;
|
||||||
|
if (opts.level) url += `&level=${opts.level}`;
|
||||||
|
if (opts.device_id) url += `&device_id=${opts.device_id}`;
|
||||||
|
if (opts.limit) url += `&limit=${opts.limit}`;
|
||||||
|
} else if (args[0] !== undefined && name !== "getRule") {
|
||||||
|
url += `&limit=${encodeURIComponent(args[0])}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fetchOpts: RequestInit = {};
|
||||||
|
if (mapping.method === "POST") {
|
||||||
|
fetchOpts.method = "POST";
|
||||||
|
fetchOpts.headers = { "Content-Type": "application/json" };
|
||||||
|
if (name === "batchSetInspectionEnabled") {
|
||||||
|
fetchOpts.body = JSON.stringify({ enabled: !!args[0] });
|
||||||
|
} else if (name === "runReportAiSummary") {
|
||||||
|
// invoke("runReportAiSummary", snapshotId, providerId)
|
||||||
|
fetchOpts.body = JSON.stringify({ snapshotId: args[0], providerId: args[1] });
|
||||||
|
} else if (name === "generateDailyReport") {
|
||||||
|
// invoke("generateDailyReport", options)
|
||||||
|
fetchOpts.body = JSON.stringify(args[0] || {});
|
||||||
|
} else {
|
||||||
|
fetchOpts.body = JSON.stringify(args[0] !== undefined ? args[0] : {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await fetch(url, fetchOpts);
|
||||||
|
const data = await res.json();
|
||||||
|
// 适配不同返回结构
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
// 优先用 arrayField 约定的字段
|
||||||
|
if (mapping.arrayField && Array.isArray(data[mapping.arrayField])) {
|
||||||
|
return data[mapping.arrayField];
|
||||||
|
}
|
||||||
|
if (data.alerts !== undefined) return data.alerts;
|
||||||
|
if (data.devices !== undefined) {
|
||||||
|
if (name === "getAllExtensions") {
|
||||||
|
// getAllExtensions 返回 Record<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.rules !== undefined) return data.rules;
|
||||||
|
if (data.results !== undefined) return data.results;
|
||||||
|
if (data.snapshots !== undefined) return data.snapshots;
|
||||||
|
if (data.reports !== undefined) return data.reports;
|
||||||
|
if (data.schedule !== undefined) return data.schedule;
|
||||||
|
if (data.ok === true && data.data !== undefined) return data.data;
|
||||||
|
if ("running" in data) return data; // schedulerStatus / snapshot-status
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Inspection] remote fetch ${name} failed:`, e);
|
||||||
|
// fetch 失败也返回安全默认值,不要让 .find 崩
|
||||||
|
if (ARRAY_METHODS.has(name)) return [];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isAvailable(): boolean {
|
function isAvailable(): boolean {
|
||||||
return !!api;
|
return !!api || IS_REMOTE_BROWSER;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invoke(name: string, ...args: any[]): Promise<any> {
|
function invoke(name: string, ...args: any[]): Promise<any> {
|
||||||
if (!api || typeof api[name] !== "function") {
|
if (api && typeof api[name] === "function") {
|
||||||
console.warn(`[Inspection] api.${name} 不可用`);
|
return api[name](...args);
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
}
|
||||||
return api[name](...args);
|
// Electron bridge 不可用 → 浏览器远程访问环境走 HTTP fallback
|
||||||
|
if (IS_REMOTE_BROWSER) {
|
||||||
|
return remoteFetch(name, ...args);
|
||||||
|
}
|
||||||
|
console.warn(`[Inspection] api.${name} 不可用`);
|
||||||
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 标签 ----------
|
// ---------- 标签 ----------
|
||||||
@@ -167,6 +288,8 @@ export const inspectionDeviceApi = {
|
|||||||
get: (hostId: string): Promise<DeviceExtension | null> => invoke("getExtension", hostId),
|
get: (hostId: string): Promise<DeviceExtension | null> => invoke("getExtension", hostId),
|
||||||
upsert: (hostId: string, data: Partial<DeviceExtension>): Promise<DeviceExtension> =>
|
upsert: (hostId: string, data: Partial<DeviceExtension>): Promise<DeviceExtension> =>
|
||||||
invoke("upsertExtension", hostId, data),
|
invoke("upsertExtension", hostId, data),
|
||||||
|
batchSetEnabled: (enabled: boolean): Promise<{ ok: boolean; count: number; enabled: boolean; error?: string }> =>
|
||||||
|
invoke("batchSetInspectionEnabled", enabled),
|
||||||
remove: (hostId: string): Promise<boolean> => invoke("deleteExtension", hostId),
|
remove: (hostId: string): Promise<boolean> => invoke("deleteExtension", hostId),
|
||||||
getEffectiveInterval: (hostId: string): Promise<number> =>
|
getEffectiveInterval: (hostId: string): Promise<number> =>
|
||||||
invoke("getEffectiveInterval", hostId),
|
invoke("getEffectiveInterval", hostId),
|
||||||
@@ -229,6 +352,8 @@ export const inspectionSchedulerApi = {
|
|||||||
status: (): Promise<SchedulerStatus> => invoke("schedulerStatus"),
|
status: (): Promise<SchedulerStatus> => invoke("schedulerStatus"),
|
||||||
scanNow: (hostId: string): Promise<any> => invoke("scanNow", hostId),
|
scanNow: (hostId: string): Promise<any> => invoke("scanNow", hostId),
|
||||||
scanAllNow: (): Promise<any> => invoke("scanAllNow"),
|
scanAllNow: (): Promise<any> => invoke("scanAllNow"),
|
||||||
|
getAutoEnabled: (): Promise<boolean> => invoke("getAutoEnabled"),
|
||||||
|
setAutoEnabled: (enabled: boolean): Promise<{ enabled: boolean }> => invoke("setAutoEnabled", enabled),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- 单次巡检执行 ----------
|
// ---------- 单次巡检执行 ----------
|
||||||
|
|||||||
99
scripts/migratePortableLocalStorage.cjs
Normal file
99
scripts/migratePortableLocalStorage.cjs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* 离线迁移脚本:把 dev Local Storage leveldb 里所有
|
||||||
|
* http://localhost:5173\x00\x01 前缀的 key
|
||||||
|
* 替换成
|
||||||
|
* app://netcatty\x00\x01
|
||||||
|
*
|
||||||
|
* 运行时机:绿色便携版打包完、给用户之前执行一次。
|
||||||
|
* 之后 Electron 启动时直接就能读正确 origin 的数据。
|
||||||
|
*
|
||||||
|
* 用法:node scripts/migratePortableLocalStorage.cjs <portableUserDataPath>
|
||||||
|
* 例:node scripts/migratePortableLocalStorage.cjs release-green\win-unpacked\data
|
||||||
|
*
|
||||||
|
* 需要:level 包(npm i --no-save level)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { Level } = require("level");
|
||||||
|
|
||||||
|
const DEV_ORIGIN = "http://localhost:5173";
|
||||||
|
const PKG_ORIGIN = "app://netcatty";
|
||||||
|
const KEY_SEP = Buffer.from([0x00, 0x01]);
|
||||||
|
// Chromium Local Storage leveldb key 格式:
|
||||||
|
// _<origin>\x00\x01<actualKey>
|
||||||
|
// 注意:origin 前有个下划线前缀!
|
||||||
|
const DEV_ORIGIN_FULL = "_" + DEV_ORIGIN;
|
||||||
|
const PKG_ORIGIN_FULL = "_" + PKG_ORIGIN;
|
||||||
|
|
||||||
|
function findSep(keyBuf, from) {
|
||||||
|
for (let i = from; i < keyBuf.length - 1; i++) {
|
||||||
|
if (keyBuf[i] === 0x00 && keyBuf[i + 1] === 0x01) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dataDir = process.argv[2];
|
||||||
|
if (!dataDir) { console.error("usage: node migratePortableLocalStorage.cjs <portableUserDataPath>"); process.exit(2); }
|
||||||
|
const lsDir = path.join(path.resolve(dataDir), "Local Storage", "leveldb");
|
||||||
|
if (!fs.existsSync(lsDir)) { console.error("leveldb dir not found:", lsDir); process.exit(2); }
|
||||||
|
|
||||||
|
// 先看看 origin 统计
|
||||||
|
const db = new Level(lsDir, { keyEncoding: "buffer", valueEncoding: "buffer", createIfMissing: false });
|
||||||
|
const counts = new Map(); // origin -> count
|
||||||
|
const ops = [];
|
||||||
|
let scanned = 0, migrated = 0;
|
||||||
|
|
||||||
|
for await (const [keyBuf, valBuf] of db.iterator()) {
|
||||||
|
scanned++;
|
||||||
|
const sepIdx = findSep(keyBuf, 0);
|
||||||
|
if (sepIdx < 0) continue;
|
||||||
|
const origin = keyBuf.subarray(0, sepIdx).toString("utf-8");
|
||||||
|
counts.set(origin, (counts.get(origin) || 0) + 1);
|
||||||
|
|
||||||
|
if (origin === DEV_ORIGIN_FULL) {
|
||||||
|
// 新 key:_app://netcatty + \x00\x01 + actualKey
|
||||||
|
const newKey = Buffer.concat([Buffer.from(PKG_ORIGIN_FULL, "utf-8"), KEY_SEP, keyBuf.subarray(sepIdx + 2)]);
|
||||||
|
ops.push({ type: "put", key: newKey, value: valBuf });
|
||||||
|
ops.push({ type: "del", key: keyBuf });
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.close();
|
||||||
|
|
||||||
|
console.log(`扫描 ${scanned} 个 key,origin 分布:`);
|
||||||
|
for (const [o, c] of counts) console.log(` ${o || "(无 origin)"} → ${c}`);
|
||||||
|
|
||||||
|
if (migrated === 0) {
|
||||||
|
console.log("\n没有 http://localhost:5173 前缀的 key 需要迁移。跳过。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备份当前 leveldb
|
||||||
|
const bak = lsDir + ".bak_" + new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
fs.cpSync(lsDir, bak, { recursive: true });
|
||||||
|
console.log(`\n已备份 leveldb → ${bak}`);
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
const db2 = new Level(lsDir, { keyEncoding: "buffer", valueEncoding: "buffer", createIfMissing: false });
|
||||||
|
await db2.batch(ops);
|
||||||
|
await db2.close();
|
||||||
|
console.log(`迁移完成:${migrated} 个 key 从 ${DEV_ORIGIN} → ${PKG_ORIGIN}`);
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
const db3 = new Level(lsDir, { keyEncoding: "buffer", valueEncoding: "buffer", createIfMissing: false });
|
||||||
|
const after = new Map();
|
||||||
|
for await (const [k] of db3.iterator()) {
|
||||||
|
const sep = findSep(k, 0);
|
||||||
|
if (sep >= 0) {
|
||||||
|
const o = k.subarray(0, sep).toString("utf-8");
|
||||||
|
after.set(o, (after.get(o) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db3.close();
|
||||||
|
console.log("\n迁移后 origin 分布:");
|
||||||
|
for (const [o, c] of after) console.log(` ${o || "(无)"} → ${c}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error("FATAL:", e.message); process.exit(1); });
|
||||||
48
scripts/scanWorkspaceDeps.cjs
Normal file
48
scripts/scanWorkspaceDeps.cjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 扫描根 package.json + 所有 workspace 子包的 dependencies/optionalDependencies,
|
||||||
|
* 找出 workspace 子包引用但根 package.json 没声明的运行时依赖(hoist 到根 node_modules 了)。
|
||||||
|
*
|
||||||
|
* 这些包 electron-builder 收集器看不到(它只读根 package.json),
|
||||||
|
* 会导致打包后 MODULE_NOT_FOUND。需要加到根 package.json 里。
|
||||||
|
*/
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const rootDir = path.resolve(__dirname, "..");
|
||||||
|
const rootPkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf-8"));
|
||||||
|
|
||||||
|
// workspace 配置
|
||||||
|
let globs = rootPkg.workspace || rootPkg.pnpm?.packages || ["packages/*"];
|
||||||
|
if (!Array.isArray(globs)) globs = ["packages/*"];
|
||||||
|
|
||||||
|
const rootRuntime = { ...rootPkg.dependencies, ...rootPkg.optionalDependencies };
|
||||||
|
|
||||||
|
const subRuntime = {}; // 子包独有的运行时依赖 → 版本
|
||||||
|
|
||||||
|
for (const g of globs) {
|
||||||
|
const dir = path.join(rootDir, g.replace("/*", ""));
|
||||||
|
if (!fs.existsSync(dir)) continue;
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!e.isDirectory()) continue;
|
||||||
|
const pkgPath = path.join(dir, e.name, "package.json");
|
||||||
|
if (!fs.existsSync(pkgPath)) continue;
|
||||||
|
const pkgJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
||||||
|
const deps = { ...pkgJson.dependencies, ...pkgJson.optionalDependencies };
|
||||||
|
for (const [name, version] of Object.entries(deps)) {
|
||||||
|
if (!rootRuntime[name] && !subRuntime[name]) {
|
||||||
|
subRuntime[name] = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingRootDeps = Object.keys(subRuntime);
|
||||||
|
console.log("子包独有的运行时依赖(根 package.json 没声明):");
|
||||||
|
console.log(" 数量:", missingRootDeps.length);
|
||||||
|
console.log(" 列表:", missingRootDeps.join(", "));
|
||||||
|
console.log("");
|
||||||
|
console.log("根 package.json 已有运行时依赖:", Object.keys(rootRuntime).length);
|
||||||
|
console.log("");
|
||||||
|
console.log("建议加到根 package.json.dependencies 的条目:");
|
||||||
|
console.log(JSON.stringify(subRuntime, null, 2));
|
||||||
Reference in New Issue
Block a user