feat: 新增设备巡检模块实现完整的巡检告警功能

新增设备巡检模块核心功能,包括:
- 巡检数据存储与调度系统
- 华为/H3C交换机及Linux服务器采集器
- 设备扩展配置与告警规则管理
- 主机详情页巡检配置入口
- 产品名称自定义设置功能
- 网络设备SSH exec通道兼容处理
This commit is contained in:
2026-10-09 10:37:48 +08:00
parent 9202506c46
commit c64e00d835
75 changed files with 12087 additions and 446 deletions

View File

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

View 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]
);
}

View File

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

View File

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

View File

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