feat: 新增设备巡检模块实现完整的巡检告警功能
新增设备巡检模块核心功能,包括: - 巡检数据存储与调度系统 - 华为/H3C交换机及Linux服务器采集器 - 设备扩展配置与告警规则管理 - 主机详情页巡检配置入口 - 产品名称自定义设置功能 - 网络设备SSH exec通道兼容处理
This commit is contained in:
@@ -49,6 +49,7 @@ import {
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_PRODUCT_NAME,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import {
|
||||
@@ -71,6 +72,7 @@ interface UseSettingsIpcSyncParams {
|
||||
syncCustomCssFromStorage: () => void;
|
||||
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
||||
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||
setProductName: Dispatch<SetStateAction<string>>;
|
||||
setTerminalThemeId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalThemeDarkId: Dispatch<SetStateAction<string>>;
|
||||
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
|
||||
@@ -117,6 +119,7 @@ export function useSettingsIpcSync({
|
||||
syncCustomCssFromStorage,
|
||||
setUiLanguage,
|
||||
setUiFontFamilyId,
|
||||
setProductName,
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeLightId,
|
||||
@@ -186,6 +189,9 @@ export function useSettingsIpcSync({
|
||||
setUiFontFamilyId(value);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_PRODUCT_NAME && typeof value === 'string') {
|
||||
setProductName(value || 'NetMesh');
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
|
||||
317
application/state/useInspectionScheduler.ts
Normal file
317
application/state/useInspectionScheduler.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 巡检调度器 Hook(前端侧)
|
||||
*
|
||||
* 为什么调度器在前端?因为 vault 主机数据在渲染进程的 localStorage 里,
|
||||
* 主进程拿不到完整的主机连接信息(密码/私钥)。
|
||||
*
|
||||
* 调度器职责:
|
||||
* - 每 30 秒检查一次哪些设备到期需要巡检
|
||||
* - 按并发限制依次调用主进程的 inspection:runInspection 执行
|
||||
* - 维护运行状态、上次执行时间
|
||||
*/
|
||||
|
||||
import { useRef, useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { inspectionExecApi, inspectionDeviceApi } from "../../infrastructure/inspection/inspectionApi";
|
||||
import type { Host } from "../../domain/models";
|
||||
import type { SSHKey } from "../../domain/models/key";
|
||||
import type { DeviceExtension } from "../../infrastructure/inspection/inspectionApi";
|
||||
|
||||
const POLL_INTERVAL_MS = 30 * 1000; // 30 秒轮询
|
||||
const DEFAULT_CONCURRENCY = 3; // 默认并发数
|
||||
const SYS_DEFAULT_INTERVAL = 5; // 系统默认周期(分钟)
|
||||
|
||||
const TIER_DEFAULT_INTERVAL: Record<string, number> = {
|
||||
core: 5,
|
||||
aggregation: 10,
|
||||
access: 15,
|
||||
};
|
||||
|
||||
export interface SchedulerStatus {
|
||||
running: boolean;
|
||||
totalDevices: number;
|
||||
activeDevices: number;
|
||||
lastScanAt: number | null;
|
||||
runningCount: number;
|
||||
}
|
||||
|
||||
interface LastRunInfo {
|
||||
[hostId: string]: number; // timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算设备的有效巡检周期(分钟)
|
||||
* 优先级:设备自定义 > 分级默认 > 系统默认
|
||||
*/
|
||||
function getEffectiveInterval(ext: DeviceExtension | null): number {
|
||||
if (!ext || !ext.inspection_enabled) return 0;
|
||||
if (ext.inspection_interval && ext.inspection_interval > 0) {
|
||||
return ext.inspection_interval;
|
||||
}
|
||||
if (ext.tier && TIER_DEFAULT_INTERVAL[ext.tier]) {
|
||||
return TIER_DEFAULT_INTERVAL[ext.tier];
|
||||
}
|
||||
return SYS_DEFAULT_INTERVAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Host + keys 拼出巡检需要的连接配置
|
||||
*/
|
||||
function buildHostConfig(host: Host, keys: SSHKey[]): {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKeyContent?: string;
|
||||
passphrase?: string;
|
||||
} {
|
||||
const config: any = {
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
};
|
||||
|
||||
// 密码
|
||||
if (host.password) {
|
||||
config.password = host.password;
|
||||
}
|
||||
|
||||
// 私钥(通过 identityFileId 关联)
|
||||
if (host.identityFileId) {
|
||||
const key = keys.find((k) => k.id === host.identityFileId);
|
||||
if (key && key.privateKey) {
|
||||
config.privateKeyContent = key.privateKey;
|
||||
if (key.passphrase) {
|
||||
config.passphrase = key.passphrase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function useInspectionScheduler(hosts: Host[], keys: SSHKey[]) {
|
||||
const [status, setStatus] = useState<SchedulerStatus>({
|
||||
running: false,
|
||||
totalDevices: 0,
|
||||
activeDevices: 0,
|
||||
lastScanAt: null,
|
||||
runningCount: 0,
|
||||
});
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastRunRef = useRef<LastRunInfo>({});
|
||||
const runningRef = useRef(false);
|
||||
const runningCountRef = useRef(0);
|
||||
const hostsRef = useRef(hosts);
|
||||
const keysRef = useRef(keys);
|
||||
|
||||
// 实时更新 hosts/keys 引用
|
||||
useEffect(() => {
|
||||
hostsRef.current = hosts;
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
keysRef.current = keys;
|
||||
}, [keys]);
|
||||
|
||||
/**
|
||||
* 执行一次单设备巡检
|
||||
*/
|
||||
const runDeviceInspection = useCallback(
|
||||
async (hostId: string, catalogId?: string) => {
|
||||
const host = hostsRef.current.find((h) => h.id === hostId);
|
||||
if (!host) {
|
||||
console.warn(`[InspectionScheduler] 未找到主机: ${hostId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostConfig = buildHostConfig(host, keysRef.current);
|
||||
const actualCatalogId = catalogId || "linux_server";
|
||||
|
||||
runningCountRef.current += 1;
|
||||
setStatus((s) => ({ ...s, runningCount: runningCountRef.current }));
|
||||
|
||||
try {
|
||||
const result = await inspectionExecApi.runInspection(
|
||||
hostId,
|
||||
hostConfig,
|
||||
actualCatalogId
|
||||
);
|
||||
lastRunRef.current[hostId] = Date.now();
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
console.error(`[InspectionScheduler] 巡检失败 ${hostId}:`, e.message);
|
||||
return { success: false, error: e.message };
|
||||
} finally {
|
||||
runningCountRef.current = Math.max(0, runningCountRef.current - 1);
|
||||
setStatus((s) => ({ ...s, runningCount: runningCountRef.current }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 主循环:检查到期设备并执行
|
||||
*/
|
||||
const scanLoop = useCallback(async (hosts?: typeof hosts) => {
|
||||
// 如果还没启动,自动启动调度器
|
||||
if (!runningRef.current) {
|
||||
runningRef.current = true;
|
||||
setStatus((s) => ({ ...s, running: true }));
|
||||
console.log("[InspectionScheduler] 调度器自动启动");
|
||||
}
|
||||
// 使用传入的 hosts 参数,或者用 ref
|
||||
const currentHosts = hosts ?? hostsRef.current;
|
||||
// hosts 还没加载,跳过
|
||||
if (!currentHosts || currentHosts.length === 0) {
|
||||
console.log('[InspectionScheduler] hosts 还未加载,当前 hosts:', currentHosts?.length);
|
||||
return;
|
||||
}
|
||||
console.log('[InspectionScheduler] 开始巡检,设备数:', currentHosts.length);
|
||||
|
||||
const allExts = await inspectionDeviceApi.getAll();
|
||||
const extsMap = allExts || {}; // Record<string, DeviceExtension>
|
||||
|
||||
// 调试:统计启用了巡检的设备数量
|
||||
let enabledCount = 0;
|
||||
for (const host of currentHosts) {
|
||||
const ext = extsMap[host.id];
|
||||
if (ext?.inspection_enabled) enabledCount++;
|
||||
}
|
||||
console.log('[InspectionScheduler] 扩展配置数量:', Object.keys(extsMap).length, '| 启用巡检设备数:', enabledCount);
|
||||
|
||||
const now = Date.now();
|
||||
const dueDevices: { hostId: string; catalogId: string }[] = [];
|
||||
|
||||
for (const host of currentHosts) {
|
||||
const ext = extsMap[host.id];
|
||||
if (!ext || !ext.inspection_enabled) continue;
|
||||
|
||||
const intervalMin = getEffectiveInterval(ext);
|
||||
if (intervalMin <= 0) {
|
||||
console.log('[InspectionScheduler] 跳过(周期为0):', host.label, '| intervalMin:', intervalMin);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastRun = lastRunRef.current[host.id] || ext.last_scan_at || 0;
|
||||
const intervalMs = intervalMin * 60 * 1000;
|
||||
const elapsed = now - lastRun;
|
||||
const needRun = elapsed >= intervalMs;
|
||||
|
||||
console.log('[InspectionScheduler] 设备:', host.label, '| 周期:', intervalMin, 'min | 距上次:', Math.round(elapsed/60000), 'min | 需要巡检:', needRun);
|
||||
|
||||
if (needRun) {
|
||||
dueDevices.push({
|
||||
hostId: host.id,
|
||||
catalogId: ext.catalog_id || "linux_server",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[InspectionScheduler] 到期设备数:', dueDevices.length);
|
||||
if (dueDevices.length === 0) {
|
||||
setStatus((s) => ({ ...s, lastScanAt: now }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 并发执行(简单的并发控制)
|
||||
const queue = [...dueDevices];
|
||||
const workers: Promise<any>[] = [];
|
||||
|
||||
const processQueue = async () => {
|
||||
while (queue.length > 0) {
|
||||
const device = queue.shift();
|
||||
if (!device) break;
|
||||
await runDeviceInspection(device.hostId, device.catalogId);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < Math.min(DEFAULT_CONCURRENCY, dueDevices.length); i++) {
|
||||
workers.push(processQueue());
|
||||
}
|
||||
|
||||
await Promise.all(workers);
|
||||
setStatus((s) => ({ ...s, lastScanAt: now }));
|
||||
}, [runDeviceInspection]);
|
||||
|
||||
/**
|
||||
* 启动调度器
|
||||
*/
|
||||
const start = useCallback(() => {
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
|
||||
// 立即执行一次
|
||||
scanLoop();
|
||||
|
||||
// 定时轮询
|
||||
timerRef.current = setInterval(() => {
|
||||
scanLoop();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
setStatus((s) => ({ ...s, running: true }));
|
||||
console.log("[InspectionScheduler] 已启动");
|
||||
}, [scanLoop]);
|
||||
|
||||
/**
|
||||
* 停止调度器
|
||||
*/
|
||||
const stop = useCallback(() => {
|
||||
runningRef.current = false;
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setStatus((s) => ({ ...s, running: false }));
|
||||
console.log("[InspectionScheduler] 已停止");
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 立即巡检指定设备
|
||||
*/
|
||||
const scanNow = useCallback(
|
||||
async (hostId: string, catalogId?: string) => {
|
||||
return await runDeviceInspection(hostId, catalogId);
|
||||
},
|
||||
[runDeviceInspection]
|
||||
);
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
runningRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 计算状态
|
||||
useEffect(() => {
|
||||
const activeCount = hosts.filter((h) => {
|
||||
// 这里没法同步拿到 extensions,先按 hosts 总数显示
|
||||
return true;
|
||||
}).length;
|
||||
|
||||
setStatus((s) => ({
|
||||
...s,
|
||||
totalDevices: hosts.length,
|
||||
activeDevices: activeCount,
|
||||
}));
|
||||
}, [hosts]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
status,
|
||||
start,
|
||||
stop,
|
||||
scanNow,
|
||||
scanLoop,
|
||||
}),
|
||||
[status, start, stop, scanNow, scanLoop]
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,11 @@ export interface ServerStats {
|
||||
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
|
||||
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
|
||||
lastUpdated: number | null; // Timestamp of last successful update
|
||||
// Network-device extended fields (H3C / Huawei / Cisco PTY-shell collector).
|
||||
// POSIX / Windows collectors never populate these.
|
||||
networkDevicePorts?: { name: string; phy: string }[];
|
||||
networkDevicePortUp?: number;
|
||||
networkDevicePortDown?: number;
|
||||
}
|
||||
|
||||
interface UseServerStatsOptions {
|
||||
@@ -217,6 +222,10 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
|
||||
netInterfaces: stats.netInterfaces || [],
|
||||
lastUpdated: Date.now(),
|
||||
// Network-device extended fields — passthrough from backend PTY-shell collector
|
||||
networkDevicePorts: Array.isArray(stats.networkDevicePorts) ? stats.networkDevicePorts : undefined,
|
||||
networkDevicePortUp: stats.networkDevicePortUp,
|
||||
networkDevicePortDown: stats.networkDevicePortDown,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_PRODUCT_NAME,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
@@ -272,6 +273,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
|
||||
});
|
||||
// 产品名称配置(用于标题栏、侧边栏 Logo 等)
|
||||
const [productName, setProductName] = useState<string>(() => {
|
||||
return localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh';
|
||||
});
|
||||
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 [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
|
||||
@@ -1163,12 +1168,32 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Persist product name
|
||||
useLayoutEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_PRODUCT_NAME, productName);
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_PRODUCT_NAME, productName);
|
||||
// 更新窗口标题栏
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setWindowTitle) {
|
||||
bridge.setWindowTitle(productName).then((result: any) => {
|
||||
console.log('[productName] setWindowTitle result:', result);
|
||||
}).catch((err: any) => {
|
||||
console.log('[productName] setWindowTitle error:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
// 发送自定义事件通知侧边栏更新
|
||||
window.dispatchEvent(new CustomEvent('productNameChanged', { detail: productName }));
|
||||
}, [productName, notifySettingsChanged]);
|
||||
|
||||
useSettingsIpcSync({
|
||||
enabled: enableSettingsSync,
|
||||
syncAppearanceFromStorage,
|
||||
syncCustomCssFromStorage,
|
||||
setUiLanguage,
|
||||
setUiFontFamilyId,
|
||||
setProductName,
|
||||
setTerminalThemeId,
|
||||
setTerminalThemeDarkId,
|
||||
setTerminalThemeLightId,
|
||||
@@ -2004,6 +2029,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setCustomAccent,
|
||||
uiFontFamilyId,
|
||||
setUiFontFamilyId,
|
||||
productName,
|
||||
setProductName,
|
||||
syncConfig,
|
||||
updateSyncConfig,
|
||||
uiLanguage,
|
||||
|
||||
@@ -1490,9 +1490,19 @@ export const useVaultState = () => {
|
||||
const savedNoteGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_NOTE_GROUPS);
|
||||
|
||||
if (savedSnippets) {
|
||||
const orderedSnippets = normalizeVaultOrder(savedSnippets);
|
||||
snippetsRef.current = orderedSnippets;
|
||||
setSnippets(orderedSnippets);
|
||||
// Merge any INITIAL_SNIPPETS entries the user doesn't have yet.
|
||||
// This lets pre-built script catalogs reach existing installations
|
||||
// without requiring users to clear storage.
|
||||
const existingIds = new Set(savedSnippets.map((s) => s.id));
|
||||
const missing = INITIAL_SNIPPETS.filter((s) => !existingIds.has(s.id));
|
||||
const merged = missing.length > 0
|
||||
? normalizeVaultOrder([...savedSnippets, ...missing])
|
||||
: normalizeVaultOrder(savedSnippets);
|
||||
if (missing.length > 0) {
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, merged);
|
||||
}
|
||||
snippetsRef.current = merged;
|
||||
setSnippets(merged);
|
||||
// Persist order backfill only when fields changed. Never rewrite the
|
||||
// pre-lock snapshot unlocked: another renderer can delete under the
|
||||
// vault lock after this read and before this write, resurrecting
|
||||
|
||||
Reference in New Issue
Block a user