feat: 新增设备巡检模块实现完整的巡检告警功能
新增设备巡检模块核心功能,包括: - 巡检数据存储与调度系统 - 华为/H3C交换机及Linux服务器采集器 - 设备扩展配置与告警规则管理 - 主机详情页巡检配置入口 - 产品名称自定义设置功能 - 网络设备SSH exec通道兼容处理
@@ -49,6 +49,7 @@ import {
|
|||||||
STORAGE_KEY_WINDOW_OPACITY,
|
STORAGE_KEY_WINDOW_OPACITY,
|
||||||
STORAGE_KEY_APP_ICON_VARIANT,
|
STORAGE_KEY_APP_ICON_VARIANT,
|
||||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||||
|
STORAGE_KEY_PRODUCT_NAME,
|
||||||
} from '../../infrastructure/config/storageKeys';
|
} from '../../infrastructure/config/storageKeys';
|
||||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +72,7 @@ interface UseSettingsIpcSyncParams {
|
|||||||
syncCustomCssFromStorage: () => void;
|
syncCustomCssFromStorage: () => void;
|
||||||
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
setUiLanguage: Dispatch<SetStateAction<UILanguage>>;
|
||||||
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
|
||||||
|
setProductName: Dispatch<SetStateAction<string>>;
|
||||||
setTerminalThemeId: Dispatch<SetStateAction<string>>;
|
setTerminalThemeId: Dispatch<SetStateAction<string>>;
|
||||||
setTerminalThemeDarkId: Dispatch<SetStateAction<string>>;
|
setTerminalThemeDarkId: Dispatch<SetStateAction<string>>;
|
||||||
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
|
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
|
||||||
@@ -117,6 +119,7 @@ export function useSettingsIpcSync({
|
|||||||
syncCustomCssFromStorage,
|
syncCustomCssFromStorage,
|
||||||
setUiLanguage,
|
setUiLanguage,
|
||||||
setUiFontFamilyId,
|
setUiFontFamilyId,
|
||||||
|
setProductName,
|
||||||
setTerminalThemeId,
|
setTerminalThemeId,
|
||||||
setTerminalThemeDarkId,
|
setTerminalThemeDarkId,
|
||||||
setTerminalThemeLightId,
|
setTerminalThemeLightId,
|
||||||
@@ -186,6 +189,9 @@ export function useSettingsIpcSync({
|
|||||||
setUiFontFamilyId(value);
|
setUiFontFamilyId(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (key === STORAGE_KEY_PRODUCT_NAME && typeof value === 'string') {
|
||||||
|
setProductName(value || 'NetMesh');
|
||||||
|
}
|
||||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||||
setTerminalThemeId(value);
|
setTerminalThemeId(value);
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
|
||||||
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
|
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
|
||||||
lastUpdated: number | null; // Timestamp of last successful update
|
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 {
|
interface UseServerStatsOptions {
|
||||||
@@ -217,6 +222,10 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
|||||||
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
|
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
|
||||||
netInterfaces: stats.netInterfaces || [],
|
netInterfaces: stats.netInterfaces || [],
|
||||||
lastUpdated: Date.now(),
|
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_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||||
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,
|
||||||
} 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 {
|
||||||
@@ -272,6 +273,10 @@ 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 等)
|
||||||
|
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 [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>(() => {
|
||||||
@@ -1163,12 +1168,32 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
|||||||
}
|
}
|
||||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
}, [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({
|
useSettingsIpcSync({
|
||||||
enabled: enableSettingsSync,
|
enabled: enableSettingsSync,
|
||||||
syncAppearanceFromStorage,
|
syncAppearanceFromStorage,
|
||||||
syncCustomCssFromStorage,
|
syncCustomCssFromStorage,
|
||||||
setUiLanguage,
|
setUiLanguage,
|
||||||
setUiFontFamilyId,
|
setUiFontFamilyId,
|
||||||
|
setProductName,
|
||||||
setTerminalThemeId,
|
setTerminalThemeId,
|
||||||
setTerminalThemeDarkId,
|
setTerminalThemeDarkId,
|
||||||
setTerminalThemeLightId,
|
setTerminalThemeLightId,
|
||||||
@@ -2004,6 +2029,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
|||||||
setCustomAccent,
|
setCustomAccent,
|
||||||
uiFontFamilyId,
|
uiFontFamilyId,
|
||||||
setUiFontFamilyId,
|
setUiFontFamilyId,
|
||||||
|
productName,
|
||||||
|
setProductName,
|
||||||
syncConfig,
|
syncConfig,
|
||||||
updateSyncConfig,
|
updateSyncConfig,
|
||||||
uiLanguage,
|
uiLanguage,
|
||||||
|
|||||||
@@ -1490,9 +1490,19 @@ export const useVaultState = () => {
|
|||||||
const savedNoteGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_NOTE_GROUPS);
|
const savedNoteGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_NOTE_GROUPS);
|
||||||
|
|
||||||
if (savedSnippets) {
|
if (savedSnippets) {
|
||||||
const orderedSnippets = normalizeVaultOrder(savedSnippets);
|
// Merge any INITIAL_SNIPPETS entries the user doesn't have yet.
|
||||||
snippetsRef.current = orderedSnippets;
|
// This lets pre-built script catalogs reach existing installations
|
||||||
setSnippets(orderedSnippets);
|
// 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
|
// Persist order backfill only when fields changed. Never rewrite the
|
||||||
// pre-lock snapshot unlocked: another renderer can delete under the
|
// pre-lock snapshot unlocked: another renderer can delete under the
|
||||||
// vault lock after this read and before this write, resurrecting
|
// vault lock after this read and before this write, resurrecting
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 696 B After Width: | Height: | Size: 1021 B |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -1,19 +1,19 @@
|
|||||||
!macro customInstall
|
!macro customInstall
|
||||||
; Open a selected folder in a new Netcatty local terminal.
|
; Open a selected folder in a new NetMesh local terminal.
|
||||||
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
; Args go after `--` so Electron/Chromium does not swallow them. Use
|
||||||
; `--open-terminal-path="%1."` so paths with spaces stay one token and drive
|
; `--open-terminal-path="%1."` so paths with spaces stay one token and drive
|
||||||
; roots (`C:\`) do not break Windows quote parsing.
|
; roots (`C:\`) do not break Windows quote parsing.
|
||||||
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty" "MUIVerb" "Open in Netcatty"
|
WriteRegStr SHCTX "Software\Classes\Directory\shell\NetMesh" "MUIVerb" "在 NetMesh 中打开终端"
|
||||||
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
|
WriteRegStr SHCTX "Software\Classes\Directory\shell\NetMesh" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
|
||||||
WriteRegStr SHCTX "Software\Classes\Directory\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%1."'
|
WriteRegStr SHCTX "Software\Classes\Directory\shell\NetMesh\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%1."'
|
||||||
|
|
||||||
; Open the directory currently displayed by Explorer.
|
; Open the directory currently displayed by Explorer.
|
||||||
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "MUIVerb" "Open in Netcatty"
|
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\NetMesh" "MUIVerb" "在 NetMesh 中打开终端"
|
||||||
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
|
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\NetMesh" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME},0"
|
||||||
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\Netcatty\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%V."'
|
WriteRegStr SHCTX "Software\Classes\Directory\Background\shell\NetMesh\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" -- --open-terminal-path="%V."'
|
||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
!macro customUnInstall
|
!macro customUnInstall
|
||||||
DeleteRegKey SHCTX "Software\Classes\Directory\shell\Netcatty"
|
DeleteRegKey SHCTX "Software\Classes\Directory\shell\NetMesh"
|
||||||
DeleteRegKey SHCTX "Software\Classes\Directory\Background\shell\Netcatty"
|
DeleteRegKey SHCTX "Software\Classes\Directory\Background\shell\NetMesh"
|
||||||
!macroend
|
!macroend
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import {
|
|||||||
type AsidePanelResizeProps,
|
type AsidePanelResizeProps,
|
||||||
} from "./ui/aside-panel";
|
} from "./ui/aside-panel";
|
||||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||||
|
import { HostInspectionSection } from "./inspection/HostInspectionSection";
|
||||||
|
import { InspectionErrorBoundary } from "./inspection/InspectionErrorBoundary";
|
||||||
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||||
import {
|
import {
|
||||||
LINUX_DISTRO_OPTION_IDS,
|
LINUX_DISTRO_OPTION_IDS,
|
||||||
@@ -1157,6 +1159,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
|||||||
clearProxyConfig={clearProxyConfig}
|
clearProxyConfig={clearProxyConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 巡检配置区 - 仅编辑已有主机时显示 */}
|
||||||
|
{initialData?.id && (
|
||||||
|
<InspectionErrorBoundary>
|
||||||
|
<HostInspectionSection
|
||||||
|
hostId={initialData.id}
|
||||||
|
hostLabel={form.label || initialData.label || "未命名"}
|
||||||
|
host={form}
|
||||||
|
keys={availableKeys}
|
||||||
|
/>
|
||||||
|
</InspectionErrorBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 py-2">
|
<div className="flex items-center gap-3 py-2">
|
||||||
<div className="flex-1 h-px bg-border/60" />
|
<div className="flex-1 h-px bg-border/60" />
|
||||||
<span className="text-xs text-muted-foreground">{t("hostDetails.otherProtocols")}</span>
|
<span className="text-xs text-muted-foreground">{t("hostDetails.otherProtocols")}</span>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { ArrowUpCircle, Bug, Check, Github, Loader2, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
|
import { ArrowUpCircle, Check, Loader2, RefreshCcw } 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 { cn } from "../lib/utils";
|
|
||||||
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";
|
||||||
@@ -16,75 +15,6 @@ type AppInfo = {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const REPO_URL = "https://github.com/binaricat/Netcatty";
|
|
||||||
const BUG_REPORT_TEMPLATE = "bug_report.yml";
|
|
||||||
|
|
||||||
const mapIssuePlatform = (platform?: string) => {
|
|
||||||
switch (platform) {
|
|
||||||
case "darwin":
|
|
||||||
return "macOS";
|
|
||||||
case "win32":
|
|
||||||
return "Windows";
|
|
||||||
case "linux":
|
|
||||||
return "Linux";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */
|
|
||||||
export const buildIssueUrl = (appInfo: AppInfo) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
template: BUG_REPORT_TEMPLATE,
|
|
||||||
title: "[Bug] ",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (appInfo.version) {
|
|
||||||
params.set("version", appInfo.version);
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform = mapIssuePlatform(appInfo.platform);
|
|
||||||
if (platform) {
|
|
||||||
params.set("platform", platform);
|
|
||||||
}
|
|
||||||
|
|
||||||
const installSource =
|
|
||||||
appInfo.version === "0.0.0"
|
|
||||||
? "Built from source (npm run dev / pack)"
|
|
||||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)";
|
|
||||||
params.set("install_source", installSource);
|
|
||||||
|
|
||||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
|
|
||||||
params.set(
|
|
||||||
"logs",
|
|
||||||
`Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return `${REPO_URL}/issues/new?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ActionRow: React.FC<{
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}> = ({ icon, title, subtitle, onClick }) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
|
|
||||||
"hover:bg-muted/50 transition-colors"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="shrink-0 text-muted-foreground">{icon}</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium leading-tight">{title}</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface SettingsApplicationTabProps {
|
interface SettingsApplicationTabProps {
|
||||||
updateState: UpdateState;
|
updateState: UpdateState;
|
||||||
checkNow: UseUpdateCheckResult['checkNow'];
|
checkNow: UseUpdateCheckResult['checkNow'];
|
||||||
@@ -92,9 +22,11 @@ interface SettingsApplicationTabProps {
|
|||||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||||
startDownload: UseUpdateCheckResult['startDownload'];
|
startDownload: UseUpdateCheckResult['startDownload'];
|
||||||
isUpdateDemoMode: boolean;
|
isUpdateDemoMode: boolean;
|
||||||
|
productName: string;
|
||||||
|
setProductName: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
|
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode, productName, setProductName }: SettingsApplicationTabProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||||
@@ -163,10 +95,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
|||||||
setTimeout(() => setLastCheckResult('none'), 3000);
|
setTimeout(() => setLastCheckResult('none'), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
|
|
||||||
const releasesUrl = `${REPO_URL}/releases`;
|
|
||||||
const discussionsUrl = `${REPO_URL}/discussions`;
|
|
||||||
|
|
||||||
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">
|
||||||
@@ -174,7 +102,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<AppLogo className="w-16 h-16" />
|
<AppLogo className="w-16 h-16" />
|
||||||
<div>
|
<div>
|
||||||
<AppWordmark accessibleLabel="Netcatty" className="h-8 w-auto text-foreground" />
|
<AppWordmark accessibleLabel={productName} className="h-8 w-auto text-foreground" />
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{appInfo.version ? appInfo.version : " "}
|
{appInfo.version ? appInfo.version : " "}
|
||||||
@@ -224,43 +152,35 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
|||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsAnchor>
|
</SettingsAnchor>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
{/* 产品名称配置 */}
|
||||||
<div className="space-y-2">
|
<SettingsAnchor anchorId="application-product-name" className="mt-6">
|
||||||
<SettingsAnchor anchorId="application-report-problem">
|
<div className="space-y-1.5">
|
||||||
<ActionRow
|
<label className="text-sm font-medium text-foreground">
|
||||||
icon={<Bug size={18} />}
|
{t('settings.application.productName') || 'Product Name'}
|
||||||
title={t("settings.application.reportProblem")}
|
</label>
|
||||||
subtitle={t("settings.application.reportProblem.subtitle")}
|
<input
|
||||||
onClick={() => void handleOpenExternal(issueUrl)}
|
type="text"
|
||||||
|
value={productName}
|
||||||
|
onChange={(e) => setProductName(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
// 失焦时确保标题栏更新
|
||||||
|
const bridge = (window as any).netcatty;
|
||||||
|
bridge?.setWindowTitle?.(productName);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="NetMesh"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
</SettingsAnchor>
|
<p className="text-xs text-muted-foreground">
|
||||||
<SettingsAnchor anchorId="application-community">
|
{t('settings.application.productNameHint') || 'Used in title bar, sidebar, and settings'}
|
||||||
<ActionRow
|
</p>
|
||||||
icon={<MessageCircle size={18} />}
|
</div>
|
||||||
title={t("settings.application.community")}
|
</SettingsAnchor>
|
||||||
subtitle={t("settings.application.community.subtitle")}
|
|
||||||
onClick={() => void handleOpenExternal(discussionsUrl)}
|
|
||||||
/>
|
|
||||||
</SettingsAnchor>
|
|
||||||
<SettingsAnchor anchorId="application-github">
|
|
||||||
<ActionRow
|
|
||||||
icon={<Github size={18} />}
|
|
||||||
title="GitHub"
|
|
||||||
subtitle={t("settings.application.github.subtitle")}
|
|
||||||
onClick={() => void handleOpenExternal(REPO_URL)}
|
|
||||||
/>
|
|
||||||
</SettingsAnchor>
|
|
||||||
<SettingsAnchor anchorId="application-whats-new">
|
|
||||||
<ActionRow
|
|
||||||
icon={<Newspaper size={18} />}
|
|
||||||
title={t("settings.application.whatsNew")}
|
|
||||||
subtitle={t("settings.application.whatsNew.subtitle")}
|
|
||||||
onClick={() => void handleOpenExternal(releasesUrl)}
|
|
||||||
/>
|
|
||||||
</SettingsAnchor>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
* Settings Page - Standalone settings window content
|
* Settings Page - Standalone settings window content
|
||||||
* This component is rendered in a separate Electron window
|
* This component is rendered in a separate Electron window
|
||||||
*/
|
*/
|
||||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
|
import { AppWindow, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useAISettingsState } from "../application/state/useAISettingsState";
|
import { useAISettingsState } from "../application/state/useAISettingsState";
|
||||||
import { useAvailableFonts } from "../application/state/fontStore";
|
import { useAvailableFonts } from "../application/state/fontStore";
|
||||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
|
||||||
import { useVaultState } from "../application/state/useVaultState";
|
|
||||||
import { useWindowControls } from "../application/state/useWindowControls";
|
import { useWindowControls } from "../application/state/useWindowControls";
|
||||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
|
||||||
import type { AppLockGateRenderContext } from "./AppLockGate";
|
import type { AppLockGateRenderContext } from "./AppLockGate";
|
||||||
import { toast } from "./ui/toast";
|
import { toast } from "./ui/toast";
|
||||||
import { SettingsTabContent } from "./settings/settings-ui";
|
import { SettingsTabContent } from "./settings/settings-ui";
|
||||||
@@ -33,9 +30,8 @@ const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsApp
|
|||||||
const LazySettingsFileAssociationsTab = lazy(() => import("./settings/tabs/SettingsFileAssociationsTab"));
|
const LazySettingsFileAssociationsTab = lazy(() => import("./settings/tabs/SettingsFileAssociationsTab"));
|
||||||
const LazySettingsShortcutsTab = lazy(() => import("./settings/tabs/SettingsShortcutsTab"));
|
const LazySettingsShortcutsTab = lazy(() => import("./settings/tabs/SettingsShortcutsTab"));
|
||||||
const LazySettingsAITab = lazy(() => import("./settings/tabs/SettingsAITab"));
|
const LazySettingsAITab = lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||||
const LazySettingsSyncTab = lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
|
||||||
const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab"));
|
|
||||||
const LazySettingsSystemTab = lazy(() => import("./settings/tabs/SettingsSystemTab"));
|
const LazySettingsSystemTab = lazy(() => import("./settings/tabs/SettingsSystemTab"));
|
||||||
|
const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab"));
|
||||||
const LazySettingsPluginsTab = lazy(() => import("./settings/tabs/SettingsPluginsTab"));
|
const LazySettingsPluginsTab = lazy(() => import("./settings/tabs/SettingsPluginsTab"));
|
||||||
|
|
||||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
@@ -237,72 +233,6 @@ const SettingsAITabContainer: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
|
||||||
const {
|
|
||||||
hosts,
|
|
||||||
keys,
|
|
||||||
identities,
|
|
||||||
proxyProfiles,
|
|
||||||
snippets,
|
|
||||||
customGroups,
|
|
||||||
snippetPackages,
|
|
||||||
notes,
|
|
||||||
noteGroups,
|
|
||||||
knownHosts,
|
|
||||||
groupConfigs,
|
|
||||||
importDataFromString,
|
|
||||||
clearVaultData,
|
|
||||||
} = useVaultState();
|
|
||||||
|
|
||||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
|
||||||
|
|
||||||
// Strip transient runtime fields before passing to sync
|
|
||||||
const portForwardingRulesForSync = useMemo(
|
|
||||||
() => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [],
|
|
||||||
[portForwardingRules],
|
|
||||||
);
|
|
||||||
|
|
||||||
const vault = useMemo(
|
|
||||||
() => ({
|
|
||||||
hosts,
|
|
||||||
keys,
|
|
||||||
identities,
|
|
||||||
proxyProfiles,
|
|
||||||
snippets,
|
|
||||||
customGroups,
|
|
||||||
snippetPackages,
|
|
||||||
notes,
|
|
||||||
noteGroups,
|
|
||||||
knownHosts,
|
|
||||||
groupConfigs,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
hosts,
|
|
||||||
keys,
|
|
||||||
identities,
|
|
||||||
proxyProfiles,
|
|
||||||
snippets,
|
|
||||||
customGroups,
|
|
||||||
snippetPackages,
|
|
||||||
notes,
|
|
||||||
noteGroups,
|
|
||||||
knownHosts,
|
|
||||||
groupConfigs,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LazySettingsSyncTab
|
|
||||||
vault={vault}
|
|
||||||
portForwardingRules={portForwardingRulesForSync}
|
|
||||||
importDataFromString={importDataFromString}
|
|
||||||
importPortForwardingRules={importPortForwardingRules}
|
|
||||||
clearVaultData={clearVaultData}
|
|
||||||
onSettingsApplied={onSettingsApplied}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
|
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { request, clearFocus, openSearch } = useSettingsFocus();
|
const { request, clearFocus, openSearch } = useSettingsFocus();
|
||||||
@@ -488,13 +418,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
|||||||
<Sparkles size={14} className={settingsTabIconClassName} />
|
<Sparkles size={14} className={settingsTabIconClassName} />
|
||||||
<span className={settingsTabLabelClassName}>{t("settings.tab.ai")}</span>
|
<span className={settingsTabLabelClassName}>{t("settings.tab.ai")}</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
|
||||||
value="sync"
|
|
||||||
className={settingsTabTriggerClassName}
|
|
||||||
>
|
|
||||||
<Cloud size={14} className={settingsTabIconClassName} />
|
|
||||||
<span className={settingsTabLabelClassName}>{t("settings.tab.syncCloud")}</span>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="system"
|
value="system"
|
||||||
className={settingsTabTriggerClassName}
|
className={settingsTabTriggerClassName}
|
||||||
@@ -515,6 +438,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
|||||||
installUpdate={installUpdate}
|
installUpdate={installUpdate}
|
||||||
startDownload={startDownload}
|
startDownload={startDownload}
|
||||||
isUpdateDemoMode={isUpdateDemoMode}
|
isUpdateDemoMode={isUpdateDemoMode}
|
||||||
|
productName={settings.productName}
|
||||||
|
setProductName={settings.setProductName}
|
||||||
/>
|
/>
|
||||||
</SettingsLazyTab>
|
</SettingsLazyTab>
|
||||||
)}
|
)}
|
||||||
@@ -619,12 +544,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
|||||||
</SettingsLazyTab>
|
</SettingsLazyTab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mountedTabs.has("sync") && (
|
|
||||||
<SettingsLazyTab value="sync">
|
|
||||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
|
||||||
</SettingsLazyTab>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mountedTabs.has("system") && (
|
{mountedTabs.has("system") && (
|
||||||
<SettingsLazyTab value="system">
|
<SettingsLazyTab value="system">
|
||||||
<LazySettingsSystemTab
|
<LazySettingsSystemTab
|
||||||
@@ -669,13 +588,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
|||||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||||
globalHotkeyEnabled={settings.globalHotkeyEnabled}
|
globalHotkeyEnabled={settings.globalHotkeyEnabled}
|
||||||
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
|
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
|
||||||
autoUpdateEnabled={settings.autoUpdateEnabled}
|
|
||||||
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
|
|
||||||
updateState={updateState}
|
|
||||||
checkNow={checkNow}
|
|
||||||
installUpdate={installUpdate}
|
|
||||||
openReleasePage={openReleasePage}
|
|
||||||
startDownload={startDownload}
|
|
||||||
/>
|
/>
|
||||||
</SettingsLazyTab>
|
</SettingsLazyTab>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ import { VaultViewLayout } from "./vault/VaultViewLayout";
|
|||||||
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
||||||
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
|
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
|
||||||
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
||||||
|
import { useInspectionScheduler } from "../application/state/useInspectionScheduler";
|
||||||
|
|
||||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||||
@@ -172,7 +173,10 @@ export type VaultSection =
|
|||||||
| "notes"
|
| "notes"
|
||||||
| "port"
|
| "port"
|
||||||
| "knownhosts"
|
| "knownhosts"
|
||||||
| "logs";
|
| "logs"
|
||||||
|
| "inspection"
|
||||||
|
| "alerts"
|
||||||
|
| "alertRules";
|
||||||
|
|
||||||
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
||||||
if (previous.length !== next.length) return false;
|
if (previous.length !== next.length) return false;
|
||||||
@@ -298,6 +302,7 @@ interface VaultViewProps {
|
|||||||
keepaliveInterval: number;
|
keepaliveInterval: number;
|
||||||
keepaliveCountMax: number;
|
keepaliveCountMax: number;
|
||||||
};
|
};
|
||||||
|
productName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VaultViewInner: React.FC<VaultViewProps> = ({
|
const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||||
@@ -327,6 +332,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
|||||||
onUpdateKeys,
|
onUpdateKeys,
|
||||||
onImportOrReuseKey,
|
onImportOrReuseKey,
|
||||||
onUpdateIdentities,
|
onUpdateIdentities,
|
||||||
|
productName,
|
||||||
onUpdateProxyProfiles,
|
onUpdateProxyProfiles,
|
||||||
onUpdateSnippets,
|
onUpdateSnippets,
|
||||||
onUpdateSnippetPackages,
|
onUpdateSnippetPackages,
|
||||||
@@ -429,6 +435,32 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [navigateToSection, onNavigateToSectionHandled]);
|
}, [navigateToSection, onNavigateToSectionHandled]);
|
||||||
|
|
||||||
|
// 巡检调度器
|
||||||
|
const inspectionScheduler = useInspectionScheduler(hosts, keys);
|
||||||
|
|
||||||
|
// 监听 hosts 加载完成后触发一次巡检(StrictMode 下多次触发也没关系,scanLoop 有 runningRef 保护)
|
||||||
|
const prevHostsLenRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hosts.length > 0 && prevHostsLenRef.current === 0) {
|
||||||
|
// hosts 从空变成非空,触发一次巡检(传入 hosts 参数,避免闭包问题)
|
||||||
|
console.log('[VaultView] hosts 加载完成,触发 scanLoop');
|
||||||
|
setTimeout(() => {
|
||||||
|
inspectionScheduler.scanLoop(hosts);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
prevHostsLenRef.current = hosts.length;
|
||||||
|
}, [hosts.length]);
|
||||||
|
|
||||||
|
// 启动周期性巡检(30 秒一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hosts.length === 0) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
console.log('[VaultView] 定时触发 scanLoop');
|
||||||
|
inspectionScheduler.scanLoop(hosts);
|
||||||
|
}, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [hosts.length]);
|
||||||
|
|
||||||
// View mode, sorting, and tag filter state
|
// View mode, sorting, and tag filter state
|
||||||
const [viewMode, setViewMode] = useStoredViewMode(
|
const [viewMode, setViewMode] = useStoredViewMode(
|
||||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||||
@@ -1532,6 +1564,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
|||||||
protocolSelectHost,
|
protocolSelectHost,
|
||||||
proxyProfiles,
|
proxyProfiles,
|
||||||
ProxyProfilesManager,
|
ProxyProfilesManager,
|
||||||
|
productName,
|
||||||
quickConnectTarget,
|
quickConnectTarget,
|
||||||
quickConnectWarnings,
|
quickConnectWarnings,
|
||||||
QuickConnectWizard,
|
QuickConnectWizard,
|
||||||
@@ -1684,7 +1717,8 @@ export const vaultViewAreEqual = (
|
|||||||
prev.terminalSettings?.keepaliveInterval ===
|
prev.terminalSettings?.keepaliveInterval ===
|
||||||
next.terminalSettings?.keepaliveInterval &&
|
next.terminalSettings?.keepaliveInterval &&
|
||||||
prev.terminalSettings?.keepaliveCountMax ===
|
prev.terminalSettings?.keepaliveCountMax ===
|
||||||
next.terminalSettings?.keepaliveCountMax;
|
next.terminalSettings?.keepaliveCountMax &&
|
||||||
|
prev.productName === next.productName;
|
||||||
|
|
||||||
return isEqual;
|
return isEqual;
|
||||||
};
|
};
|
||||||
|
|||||||
528
components/inspection/AlertCenterPage.tsx
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
MessageSquare,
|
||||||
|
RefreshCw,
|
||||||
|
Filter,
|
||||||
|
Info,
|
||||||
|
AlertOctagon,
|
||||||
|
Bell,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import {
|
||||||
|
inspectionAlertApi,
|
||||||
|
type AlertRecord,
|
||||||
|
type AlertLevel,
|
||||||
|
type AlertRecordStatus,
|
||||||
|
type AlertStats,
|
||||||
|
} from "../../infrastructure/inspection/inspectionApi";
|
||||||
|
import { toast } from "../ui/toast";
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
|
||||||
|
critical: {
|
||||||
|
bg: "bg-red-500/10",
|
||||||
|
text: "text-red-500",
|
||||||
|
border: "border-red-500/30",
|
||||||
|
dot: "bg-red-500",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: "bg-orange-500/10",
|
||||||
|
text: "text-orange-500",
|
||||||
|
border: "border-orange-500/30",
|
||||||
|
dot: "bg-orange-500",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: "bg-blue-500/10",
|
||||||
|
text: "text-blue-500",
|
||||||
|
border: "border-blue-500/30",
|
||||||
|
dot: "bg-blue-500",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<AlertLevel, string> = {
|
||||||
|
critical: "严重",
|
||||||
|
warning: "警告",
|
||||||
|
info: "提示",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<AlertRecordStatus, string> = {
|
||||||
|
active: "待处理",
|
||||||
|
acknowledged: "已确认",
|
||||||
|
resolved: "已恢复",
|
||||||
|
closed: "已关闭",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<AlertRecordStatus, string> = {
|
||||||
|
active: "text-red-500",
|
||||||
|
acknowledged: "text-orange-500",
|
||||||
|
resolved: "text-green-500",
|
||||||
|
closed: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlertCenterPage() {
|
||||||
|
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
|
||||||
|
const [stats, setStats] = useState<AlertStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedAlert, setSelectedAlert] = useState<AlertRecord | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [noteDialogOpen, setNoteDialogOpen] = useState(false);
|
||||||
|
const [noteText, setNoteText] = useState("");
|
||||||
|
|
||||||
|
// 筛选
|
||||||
|
const [filterLevel, setFilterLevel] = useState<string>("all");
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState<"active" | "all">("active");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [statsData, alertsData] = await Promise.all([
|
||||||
|
inspectionAlertApi.getStats(),
|
||||||
|
activeTab === "active"
|
||||||
|
? inspectionAlertApi.getActive()
|
||||||
|
: inspectionAlertApi.list({ limit: 200 }),
|
||||||
|
]);
|
||||||
|
setStats(statsData);
|
||||||
|
setAlerts(alertsData);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[AlertCenter] 加载失败:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 筛选后的数据
|
||||||
|
const filteredAlerts = (alerts ?? []).filter((a) => {
|
||||||
|
if (filterLevel !== "all" && a.level !== filterLevel) return false;
|
||||||
|
if (filterStatus !== "all" && a.status !== filterStatus) return false;
|
||||||
|
if (searchText) {
|
||||||
|
const s = searchText.toLowerCase();
|
||||||
|
if (
|
||||||
|
!a.device_name.toLowerCase().includes(s) &&
|
||||||
|
!a.rule_name.toLowerCase().includes(s) &&
|
||||||
|
!a.message.toLowerCase().includes(s)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleViewDetail = (alert: AlertRecord) => {
|
||||||
|
setSelectedAlert(alert);
|
||||||
|
setDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcknowledge = async () => {
|
||||||
|
if (!selectedAlert) return;
|
||||||
|
try {
|
||||||
|
await inspectionAlertApi.acknowledge(selectedAlert.id, "operator");
|
||||||
|
toast.success("已确认告警");
|
||||||
|
setDetailOpen(false);
|
||||||
|
loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("操作失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = async () => {
|
||||||
|
if (!selectedAlert) return;
|
||||||
|
try {
|
||||||
|
await inspectionAlertApi.resolve(selectedAlert.id, "manual", "operator");
|
||||||
|
toast.success("已恢复告警");
|
||||||
|
setDetailOpen(false);
|
||||||
|
loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("操作失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!selectedAlert) return;
|
||||||
|
setNoteText("");
|
||||||
|
setNoteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseConfirm = async () => {
|
||||||
|
if (!selectedAlert) return;
|
||||||
|
try {
|
||||||
|
await inspectionAlertApi.close(selectedAlert.id, noteText);
|
||||||
|
toast.success("已关闭告警");
|
||||||
|
setNoteDialogOpen(false);
|
||||||
|
setDetailOpen(false);
|
||||||
|
loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("操作失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (ts: number) => {
|
||||||
|
return new Date(ts).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeAgo = (ts: number) => {
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return "刚刚";
|
||||||
|
if (mins < 60) return `${mins} 分钟前`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours} 小时前`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days} 天前`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 顶部统计 */}
|
||||||
|
<div className="px-6 py-4 border-b border-border/60">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-red-500/10 flex items-center justify-center">
|
||||||
|
<Bell size={18} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">告警中心</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
监控设备巡检产生的告警
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={loadData}>
|
||||||
|
<RefreshCw size={14} className="mr-2" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="p-3 rounded-lg border border-red-500/30 bg-red-500/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertOctagon size={16} className="text-red-500" />
|
||||||
|
<span className="text-sm text-muted-foreground">严重告警</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-red-500 mt-1">
|
||||||
|
{stats?.active_critical ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg border border-orange-500/30 bg-orange-500/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} className="text-orange-500" />
|
||||||
|
<span className="text-sm text-muted-foreground">警告</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-orange-500 mt-1">
|
||||||
|
{stats?.active_warning ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info size={16} className="text-blue-500" />
|
||||||
|
<span className="text-sm text-muted-foreground">提示</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-500 mt-1">
|
||||||
|
{stats?.active_info ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
<span className="text-sm text-muted-foreground">已恢复</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-500 mt-1">
|
||||||
|
{stats?.resolved ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/60">
|
||||||
|
<div className="flex bg-secondary/50 rounded-md p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("active")}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||||
|
activeTab === "active"
|
||||||
|
? "bg-background shadow-sm font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
待处理
|
||||||
|
{stats && stats.active > 0 && (
|
||||||
|
<Badge className="ml-2" variant="secondary">
|
||||||
|
{stats.active}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("all")}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||||
|
activeTab === "all"
|
||||||
|
? "bg-background shadow-sm font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Select value={filterLevel} onValueChange={setFilterLevel}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="级别" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部级别</SelectItem>
|
||||||
|
<SelectItem value="critical">严重</SelectItem>
|
||||||
|
<SelectItem value="warning">警告</SelectItem>
|
||||||
|
<SelectItem value="info">提示</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{activeTab === "all" && (
|
||||||
|
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="active">待处理</SelectItem>
|
||||||
|
<SelectItem value="acknowledged">已确认</SelectItem>
|
||||||
|
<SelectItem value="resolved">已恢复</SelectItem>
|
||||||
|
<SelectItem value="closed">已关闭</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="搜索设备/规则..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 告警列表 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
||||||
|
) : filteredAlerts.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-16">
|
||||||
|
<CheckCircle size={40} className="mx-auto mb-3 text-green-500 opacity-40" />
|
||||||
|
<p className="text-sm">暂无告警记录</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{activeTab === "active" ? "所有设备运行正常" : "还没有产生过告警"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{filteredAlerts.map((alert) => {
|
||||||
|
const colors = LEVEL_COLORS[alert.level];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
onClick={() => handleViewDetail(alert)}
|
||||||
|
className="flex items-center gap-4 px-6 py-3 hover:bg-accent/30 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
{/* 级别指示点 */}
|
||||||
|
<div className={`w-2 h-2 rounded-full ${colors.dot}`} />
|
||||||
|
|
||||||
|
{/* 主要内容 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="font-medium truncate">{alert.device_name}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${colors.bg} ${colors.text} ${colors.border} border`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[alert.level]}
|
||||||
|
</Badge>
|
||||||
|
<span className={`text-xs ${STATUS_COLORS[alert.status]}`}>
|
||||||
|
{STATUS_LABELS[alert.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground truncate">
|
||||||
|
{alert.rule_name} — {alert.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 时间 */}
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{getTimeAgo(alert.triggered_at)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground/60">
|
||||||
|
{formatTime(alert.triggered_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight size={16} className="text-muted-foreground shrink-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 详情弹窗 */}
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
{selectedAlert && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${LEVEL_COLORS[selectedAlert.level].dot}`}
|
||||||
|
/>
|
||||||
|
告警详情
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">设备名称</div>
|
||||||
|
<div className="text-sm font-medium">{selectedAlert.device_name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">告警级别</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${LEVEL_COLORS[selectedAlert.level].bg} ${
|
||||||
|
LEVEL_COLORS[selectedAlert.level].text
|
||||||
|
} ${LEVEL_COLORS[selectedAlert.level].border} border`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[selectedAlert.level]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">规则名称</div>
|
||||||
|
<div className="text-sm">{selectedAlert.rule_name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">告警消息</div>
|
||||||
|
<div className="text-sm p-2 bg-secondary/30 rounded-md">
|
||||||
|
{selectedAlert.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">当前值</div>
|
||||||
|
<div className="text-sm font-mono">{selectedAlert.metric_value}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">阈值</div>
|
||||||
|
<div className="text-sm font-mono">{selectedAlert.threshold}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">触发时间</div>
|
||||||
|
<div className="text-sm">{formatTime(selectedAlert.triggered_at)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">当前状态</div>
|
||||||
|
<div className={`text-sm font-medium ${STATUS_COLORS[selectedAlert.status]}`}>
|
||||||
|
{STATUS_LABELS[selectedAlert.status]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedAlert.note && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">备注</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{selectedAlert.note}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={() => setDetailOpen(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selectedAlert.status === "active" && (
|
||||||
|
<Button variant="outline" onClick={handleAcknowledge}>
|
||||||
|
<MessageSquare size={14} className="mr-2" />
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(selectedAlert.status === "active" ||
|
||||||
|
selectedAlert.status === "acknowledged") && (
|
||||||
|
<Button variant="outline" onClick={handleResolve}>
|
||||||
|
<CheckCircle size={14} className="mr-2" />
|
||||||
|
恢复
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedAlert.status !== "closed" && (
|
||||||
|
<Button variant="destructive" onClick={handleClose}>
|
||||||
|
<XCircle size={14} className="mr-2" />
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 关闭备注弹窗 */}
|
||||||
|
<Dialog open={noteDialogOpen} onOpenChange={setNoteDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>关闭告警</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<Label>关闭原因(可选)</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="请输入关闭原因或处理备注..."
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setNoteDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleCloseConfirm}>
|
||||||
|
确认关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertCenterPage;
|
||||||
589
components/inspection/AlertRulesPage.tsx
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
import { Plus, Trash2, Edit2, AlertTriangle, Bell, Settings2, Power, PowerOff } from "lucide-react";
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Switch } from "../ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import {
|
||||||
|
inspectionAlertRuleApi,
|
||||||
|
inspectionCatalogApi,
|
||||||
|
type AlertRule,
|
||||||
|
type AlertLevel,
|
||||||
|
type Operator,
|
||||||
|
type DeviceCatalog,
|
||||||
|
} from "../../infrastructure/inspection/inspectionApi";
|
||||||
|
import { toast } from "../ui/toast";
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS: { value: AlertLevel; label: string; color: string }[] = [
|
||||||
|
{ value: "critical", label: "严重", color: "text-red-500 bg-red-500/10 border-red-500/30" },
|
||||||
|
{ value: "warning", label: "警告", color: "text-orange-500 bg-orange-500/10 border-orange-500/30" },
|
||||||
|
{ value: "info", label: "提示", color: "text-blue-500 bg-blue-500/10 border-blue-500/30" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPERATOR_OPTIONS: { value: Operator; label: string }[] = [
|
||||||
|
{ value: ">", label: "大于 ( > )" },
|
||||||
|
{ value: "<", label: "小于 ( < )" },
|
||||||
|
{ value: ">=", label: "大于等于 ( >= )" },
|
||||||
|
{ value: "<=", label: "小于等于 ( <= )" },
|
||||||
|
{ value: "==", label: "等于 ( == )" },
|
||||||
|
{ value: "!=", label: "不等于 ( != )" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMMON_METRICS = [
|
||||||
|
{ value: "cpu_usage", label: "CPU 使用率 (%)", group: "通用" },
|
||||||
|
{ value: "memory_usage", label: "内存使用率 (%)", group: "通用" },
|
||||||
|
{ value: "disk_usage", label: "磁盘使用率 (%)", group: "通用" },
|
||||||
|
{ value: "load_1m", label: "系统负载 (1分钟)", group: "通用" },
|
||||||
|
{ value: "online", label: "设备在线状态", group: "通用" },
|
||||||
|
{ value: "temperature", label: "设备温度 (°C)", group: "网络设备" },
|
||||||
|
{ value: "power_failure_count", label: "电源故障数", group: "网络设备" },
|
||||||
|
{ value: "fan_failure_count", label: "风扇故障数", group: "网络设备" },
|
||||||
|
{ value: "port_down_count", label: "端口 Down 数", group: "网络设备" },
|
||||||
|
{ value: "port_total_count", label: "端口总数", group: "网络设备" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOPE_OPTIONS = [
|
||||||
|
{ value: "all", label: "全部设备" },
|
||||||
|
{ value: "tier", label: "按设备分级" },
|
||||||
|
{ value: "catalog", label: "按设备类型" },
|
||||||
|
{ value: "device", label: "指定设备" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AlertRulesPage() {
|
||||||
|
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||||
|
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingRule, setEditingRule] = useState<AlertRule | null>(null);
|
||||||
|
|
||||||
|
// 筛选条件
|
||||||
|
const [filterLevel, setFilterLevel] = useState<"all" | AlertLevel>("all");
|
||||||
|
const [filterScope, setFilterScope] = useState<"all" | "preset" | "custom">("all");
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
metric: "cpu_usage",
|
||||||
|
operator: ">" as Operator,
|
||||||
|
threshold: "80",
|
||||||
|
level: "warning" as AlertLevel,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "all" as "all" | "tier" | "catalog" | "device",
|
||||||
|
scope_value: "" as string,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadRules = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [list, catList] = await Promise.all([
|
||||||
|
inspectionAlertRuleApi.list(),
|
||||||
|
inspectionCatalogApi.list(),
|
||||||
|
]);
|
||||||
|
setRules(list);
|
||||||
|
setCatalogs(catList);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[AlertRules] 加载失败:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRules();
|
||||||
|
}, [loadRules]);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingRule(null);
|
||||||
|
setForm({
|
||||||
|
name: "",
|
||||||
|
metric: "cpu_usage",
|
||||||
|
operator: ">",
|
||||||
|
threshold: "80",
|
||||||
|
level: "warning",
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "all",
|
||||||
|
scope_value: "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (rule: AlertRule) => {
|
||||||
|
setEditingRule(rule);
|
||||||
|
setForm({
|
||||||
|
name: rule.name,
|
||||||
|
metric: rule.metric,
|
||||||
|
operator: rule.operator,
|
||||||
|
threshold: rule.threshold,
|
||||||
|
level: rule.level,
|
||||||
|
duration: rule.duration,
|
||||||
|
scope_type: rule.scope_type as any,
|
||||||
|
scope_value: Array.isArray(rule.scope_value)
|
||||||
|
? rule.scope_value.join(", ")
|
||||||
|
: rule.scope_value || "",
|
||||||
|
enabled: rule.enabled,
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error("请输入规则名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.threshold.trim()) {
|
||||||
|
toast.error("请输入阈值");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: Partial<AlertRule> = {
|
||||||
|
name: form.name,
|
||||||
|
metric: form.metric,
|
||||||
|
operator: form.operator,
|
||||||
|
threshold: form.threshold,
|
||||||
|
level: form.level,
|
||||||
|
duration: form.duration,
|
||||||
|
scope_type: form.scope_type,
|
||||||
|
scope_value:
|
||||||
|
form.scope_type === "all"
|
||||||
|
? null
|
||||||
|
: form.scope_type === "device"
|
||||||
|
? form.scope_value.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: form.scope_value || null,
|
||||||
|
enabled: form.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingRule) {
|
||||||
|
await inspectionAlertRuleApi.update(editingRule.id, data);
|
||||||
|
toast.success("规则已更新");
|
||||||
|
} else {
|
||||||
|
await inspectionAlertRuleApi.create(data);
|
||||||
|
toast.success("规则已创建");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
loadRules();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("保存失败: " + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (ruleId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await inspectionAlertRuleApi.toggle(ruleId, enabled);
|
||||||
|
toast.success(enabled ? "已启用" : "已停用");
|
||||||
|
loadRules();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("操作失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (ruleId: string) => {
|
||||||
|
if (!confirm("确定要删除这条规则吗?")) return;
|
||||||
|
try {
|
||||||
|
await inspectionAlertRuleApi.remove(ruleId);
|
||||||
|
toast.success("已删除");
|
||||||
|
loadRules();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelBadge = (level: AlertLevel) => {
|
||||||
|
const opt = LEVEL_OPTIONS.find((l) => l.value === level);
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={opt?.color}>
|
||||||
|
{opt?.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScopeLabel = (rule: AlertRule) => {
|
||||||
|
switch (rule.scope_type) {
|
||||||
|
case "all":
|
||||||
|
return "全部设备";
|
||||||
|
case "tier":
|
||||||
|
const tierLabels: Record<string, string> = {
|
||||||
|
core: "核心级",
|
||||||
|
aggregation: "重要级",
|
||||||
|
access: "一般",
|
||||||
|
};
|
||||||
|
return `分级: ${tierLabels[String(rule.scope_value)] || rule.scope_value}`;
|
||||||
|
case "catalog":
|
||||||
|
const cat = catalogs.find((c) => c.id === rule.scope_value);
|
||||||
|
return `类型: ${cat?.name || rule.scope_value}`;
|
||||||
|
case "device":
|
||||||
|
return `指定设备 (${Array.isArray(rule.scope_value) ? rule.scope_value.length : 0})`;
|
||||||
|
default:
|
||||||
|
return rule.scope_type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 筛选后的数据
|
||||||
|
const filteredRules = (rules ?? []).filter((r) => {
|
||||||
|
if (filterLevel !== "all" && r.level !== filterLevel) return false;
|
||||||
|
if (filterScope === "preset" && !r.preset) return false;
|
||||||
|
if (filterScope === "custom" && r.preset) return false;
|
||||||
|
if (searchText) {
|
||||||
|
const s = searchText.toLowerCase();
|
||||||
|
if (!r.name.toLowerCase().includes(s) && !r.metric.toLowerCase().includes(s)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 顶部工具栏 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/60">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<AlertTriangle size={18} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">告警规则配置</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
定义设备巡检的告警触发条件(共 {(rules?.length ?? 0)} 条)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
新增规则
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/40 bg-muted/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">级别:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ value: "all", label: "全部" },
|
||||||
|
{ value: "critical", label: "严重" },
|
||||||
|
{ value: "warning", label: "警告" },
|
||||||
|
{ value: "info", label: "提示" },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setFilterLevel(opt.value as any)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
||||||
|
filterLevel === opt.value
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background hover:bg-accent text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-4 bg-border/60" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">分类:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[
|
||||||
|
{ value: "all", label: "全部" },
|
||||||
|
{ value: "preset", label: "预置" },
|
||||||
|
{ value: "custom", label: "自定义" },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setFilterScope(opt.value as any)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
||||||
|
filterScope === opt.value
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background hover:bg-accent text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="relative w-56">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索规则名称..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 规则列表 */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">加载中...</div>
|
||||||
|
) : filteredRules.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12">
|
||||||
|
<Bell size={40} className="mx-auto mb-3 opacity-40" />
|
||||||
|
<p>暂无符合条件的告警规则</p>
|
||||||
|
<p className="text-sm">点击右上角新增规则开始配置</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredRules.map((rule) => (
|
||||||
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
className="flex items-center gap-4 p-4 border border-border/60 rounded-lg hover:bg-accent/30 transition-colors"
|
||||||
|
>
|
||||||
|
{/* 启用开关 */}
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
checked={rule.enabled}
|
||||||
|
onCheckedChange={(v) => handleToggle(rule.id, v)}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{rule.enabled ? "启用" : "停用"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 规则信息 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium">{rule.name}</span>
|
||||||
|
{getLevelBadge(rule.level)}
|
||||||
|
{rule.preset && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
预置
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-3">
|
||||||
|
<span>
|
||||||
|
{COMMON_METRICS.find((m) => m.value === rule.metric)?.label || rule.metric}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">{rule.operator}</span>
|
||||||
|
<span>{rule.threshold}</span>
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
<span>{getScopeLabel(rule)}</span>
|
||||||
|
{rule.duration > 1 && (
|
||||||
|
<>
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
<span>持续 {rule.duration} 次</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleEdit(rule)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit2 size={15} />
|
||||||
|
</Button>
|
||||||
|
{!rule.preset && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(rule.id)}
|
||||||
|
title="删除"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑弹窗 */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingRule ? "编辑告警规则" : "新增告警规则"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* 规则名称 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>规则名称</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="例如:CPU 使用率过高"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 指标 + 操作符 + 阈值 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>指标</Label>
|
||||||
|
<Select value={form.metric} onValueChange={(v) => setForm({ ...form, metric: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{COMMON_METRICS.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>条件</Label>
|
||||||
|
<Select
|
||||||
|
value={form.operator}
|
||||||
|
onValueChange={(v) => setForm({ ...form, operator: v as Operator })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OPERATOR_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>阈值</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="80"
|
||||||
|
value={form.threshold}
|
||||||
|
onChange={(e) => setForm({ ...form, threshold: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 告警级别 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>告警级别</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{LEVEL_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setForm({ ...form, level: opt.value })}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-md border text-sm font-medium transition-colors ${
|
||||||
|
form.level === opt.value
|
||||||
|
? `border-primary/50 ${opt.color}`
|
||||||
|
: "border-border hover:border-primary/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 适用范围 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>适用范围</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
value={form.scope_type}
|
||||||
|
onValueChange={(v) => setForm({ ...form, scope_type: v as any })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCOPE_OPTIONS.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{form.scope_type !== "all" && (
|
||||||
|
<Select
|
||||||
|
value={form.scope_value}
|
||||||
|
onValueChange={(v) => setForm({ ...form, scope_value: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="请选择" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{form.scope_type === "catalog" &&
|
||||||
|
catalogs.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{form.scope_type === "tier" && (
|
||||||
|
<>
|
||||||
|
<SelectItem value="core">核心级</SelectItem>
|
||||||
|
<SelectItem value="aggregation">重要级</SelectItem>
|
||||||
|
<SelectItem value="access">一般</SelectItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{form.scope_type === "device" && (
|
||||||
|
<Input
|
||||||
|
placeholder="输入设备 ID,多个用逗号分隔"
|
||||||
|
value={form.scope_value}
|
||||||
|
onChange={(e) => setForm({ ...form, scope_value: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 持续次数 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>持续次数(防抖动)</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={form.duration}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, duration: parseInt(e.target.value, 10) || 1 })
|
||||||
|
}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
连续触发 N 次后才产生告警,避免瞬时波动误报
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingRule ? "保存修改" : "创建规则"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertRulesPage;
|
||||||
298
components/inspection/HostInspectionSection.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { Activity, AlertTriangle, Clock, Cpu, Layers, Settings2, Wifi } from "lucide-react";
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Switch } from "../ui/switch";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import {
|
||||||
|
inspectionDeviceApi,
|
||||||
|
inspectionCatalogApi,
|
||||||
|
inspectionExecApi,
|
||||||
|
type DeviceCatalog,
|
||||||
|
type DeviceExtension,
|
||||||
|
type DeviceTierInfo,
|
||||||
|
type DeviceTier,
|
||||||
|
} from "../../infrastructure/inspection/inspectionApi";
|
||||||
|
import { toast } from "../ui/toast";
|
||||||
|
import { HostDetailsSection } from "../host-details";
|
||||||
|
|
||||||
|
interface HostInspectionSectionProps {
|
||||||
|
hostId: string;
|
||||||
|
hostLabel: string;
|
||||||
|
host: any;
|
||||||
|
keys: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostInspectionSection({ hostId, hostLabel, host, keys }: HostInspectionSectionProps) {
|
||||||
|
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
||||||
|
const [tiers, setTiers] = useState<DeviceTierInfo[]>([]);
|
||||||
|
const [extension, setExtension] = useState<DeviceExtension | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 本地编辑状态
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [catalogId, setCatalogId] = useState("");
|
||||||
|
const [tier, setTier] = useState<string>("");
|
||||||
|
const [customInterval, setCustomInterval] = useState<string>("");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [catalogList, tierList, ext] = await Promise.all([
|
||||||
|
inspectionCatalogApi.list(),
|
||||||
|
inspectionCatalogApi.getTiers(),
|
||||||
|
inspectionDeviceApi.get(hostId),
|
||||||
|
]);
|
||||||
|
const safeCatalogs = Array.isArray(catalogList) ? catalogList : [];
|
||||||
|
const safeTiers = Array.isArray(tierList) ? tierList : [];
|
||||||
|
setCatalogs(safeCatalogs);
|
||||||
|
setTiers(safeTiers);
|
||||||
|
setExtension(ext || null);
|
||||||
|
|
||||||
|
if (ext) {
|
||||||
|
setEnabled(ext.inspection_enabled);
|
||||||
|
setCatalogId(ext.catalog_id);
|
||||||
|
setTier(ext.tier || "");
|
||||||
|
setCustomInterval(ext.inspection_interval?.toString() || "");
|
||||||
|
} else {
|
||||||
|
// 默认值
|
||||||
|
setCatalogId(safeCatalogs[0]?.id || "linux");
|
||||||
|
setTier("access");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[Inspection] 加载失败:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [hostId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!catalogId) {
|
||||||
|
toast.error("请选择设备类型");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const data: Partial<DeviceExtension> = {
|
||||||
|
inspection_enabled: enabled,
|
||||||
|
catalog_id: catalogId,
|
||||||
|
tier: (tier as DeviceTier) || null,
|
||||||
|
inspection_interval: customInterval ? parseInt(customInterval, 10) : null,
|
||||||
|
// 存 hostname,用于调度器匹配 vault SSH 配置
|
||||||
|
hostname: host?.hostname || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await inspectionDeviceApi.upsert(hostId, data);
|
||||||
|
setExtension(result);
|
||||||
|
toast.success(enabled ? "巡检已启用" : "巡检已停用");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("保存失败: " + e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanNow = async () => {
|
||||||
|
try {
|
||||||
|
if (!host || !host.hostname) {
|
||||||
|
toast.error("主机配置不完整,无法巡检");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造连接配置
|
||||||
|
const hostConfig: any = {
|
||||||
|
id: host.id || hostId,
|
||||||
|
label: hostLabel,
|
||||||
|
hostname: host.hostname,
|
||||||
|
port: host.port || 22,
|
||||||
|
username: host.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 密码
|
||||||
|
if (host.password) {
|
||||||
|
hostConfig.password = host.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 私钥
|
||||||
|
if (host.identityFileId && keys && keys.length) {
|
||||||
|
const key = keys.find((k) => k.id === host.identityFileId);
|
||||||
|
if (key && key.privateKey) {
|
||||||
|
hostConfig.privateKeyContent = key.privateKey;
|
||||||
|
if (key.passphrase) {
|
||||||
|
hostConfig.passphrase = key.passphrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.info(`正在对 ${hostLabel} 执行立即巡检...`);
|
||||||
|
const result = await inspectionExecApi.runInspection(hostId, hostConfig, catalogId);
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
toast.success(`巡检完成,耗时 ${(result.duration / 1000).toFixed(1)} 秒`);
|
||||||
|
if (result.alerts && result.alerts.length > 0) {
|
||||||
|
toast.warning(`触发 ${result.alerts.length} 条告警`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(`巡检失败: ${result?.error || "未知错误"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新扩展信息(更新 last_run_at)
|
||||||
|
loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("巡检触发失败: " + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算有效周期
|
||||||
|
const selectedTier = tiers.find((t) => t.key === tier);
|
||||||
|
const selectedCatalog = catalogs.find((c) => c.id === catalogId);
|
||||||
|
const effectiveInterval = customInterval
|
||||||
|
? `${customInterval} 分钟`
|
||||||
|
: selectedTier
|
||||||
|
? `${selectedTier.default_interval} 分钟(${selectedTier.label}默认)`
|
||||||
|
: "5 分钟(系统默认)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HostDetailsSection
|
||||||
|
icon={<Activity size={16} />}
|
||||||
|
title="巡检配置"
|
||||||
|
hint={`为 ${hostLabel} 配置自动巡检`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 启用开关 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium">启用自动巡检</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
开启后系统将按周期自动巡检该设备
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备类型 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">设备类型</Label>
|
||||||
|
<Select value={catalogId} onValueChange={setCatalogId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择设备类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{catalogs.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{c.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{c.vendor}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedCatalog && (
|
||||||
|
<p className="text-xs text-muted-foreground">{selectedCatalog.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备分级 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">设备分级</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{tiers.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTier(tier === t.key ? "" : t.key)}
|
||||||
|
className={`flex-1 px-3 py-2 rounded-md border text-sm transition-colors ${
|
||||||
|
tier === t.key
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{t.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
默认 {t.default_interval} 分钟
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
分级决定默认巡检周期,也可单独设置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义周期 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
自定义巡检周期(分钟,留空则使用默认)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="留空使用默认值"
|
||||||
|
value={customInterval}
|
||||||
|
onChange={(e) => setCustomInterval(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
有效周期:<span className="font-medium">{effectiveInterval}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上次巡检信息 */}
|
||||||
|
{extension?.last_scan_at && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-secondary/30 rounded-md text-sm">
|
||||||
|
<Clock size={14} className="text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">上次巡检:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(extension.last_scan_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={extension.last_scan_status === "success" ? "default" : "destructive"}
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
{extension.last_scan_status === "success"
|
||||||
|
? "成功"
|
||||||
|
: extension.last_scan_status === "timeout"
|
||||||
|
? "超时"
|
||||||
|
: "失败"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button onClick={handleSave} disabled={saving} className="flex-1">
|
||||||
|
{saving ? "保存中..." : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleScanNow}
|
||||||
|
disabled={!enabled}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Activity size={14} className="mr-2" />
|
||||||
|
立即巡检
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</HostDetailsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
847
components/inspection/InspectionCenterPage.tsx
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Play,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
ListIcon,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Switch } from "../ui/switch";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import {
|
||||||
|
inspectionDeviceApi,
|
||||||
|
inspectionCatalogApi,
|
||||||
|
inspectionResultApi,
|
||||||
|
inspectionExecApi,
|
||||||
|
inspectionAlertApi,
|
||||||
|
type DeviceExtension,
|
||||||
|
type DeviceCatalog,
|
||||||
|
type InspectionResult,
|
||||||
|
type AlertStats,
|
||||||
|
} from "../../infrastructure/inspection/inspectionApi";
|
||||||
|
import { useVaultState } from "../../application/state/useVaultState";
|
||||||
|
import { toast } from "../ui/toast";
|
||||||
|
|
||||||
|
const TIER_LABELS: Record<string, string> = {
|
||||||
|
core: "核心级",
|
||||||
|
aggregation: "重要级",
|
||||||
|
access: "一般",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DeviceRow {
|
||||||
|
hostId: string;
|
||||||
|
hostName: string;
|
||||||
|
hostAddress: string;
|
||||||
|
hostPlatform: string;
|
||||||
|
catalogId: string;
|
||||||
|
catalogName: string;
|
||||||
|
tier: string;
|
||||||
|
enabled: boolean;
|
||||||
|
interval: number;
|
||||||
|
intervalLabel: string;
|
||||||
|
lastStatus: "success" | "failed" | "never";
|
||||||
|
lastTime: number | null;
|
||||||
|
lastResult?: InspectionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectionCenterPage() {
|
||||||
|
const { hosts, keys } = useVaultState();
|
||||||
|
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
|
||||||
|
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
||||||
|
const [latestResults, setLatestResults] = useState<Record<string, InspectionResult>>({});
|
||||||
|
const [alertStats, setAlertStats] = useState<AlertStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// 筛选
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [filterCatalog, setFilterCatalog] = useState<string>("all");
|
||||||
|
const [filterTier, setFilterTier] = useState<string>("all");
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||||
|
|
||||||
|
// 详情抽屉
|
||||||
|
const [detailHostId, setDetailHostId] = useState<string | null>(null);
|
||||||
|
const [detailResults, setDetailResults] = useState<InspectionResult[]>([]);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
// 配置弹窗
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
|
const [configHostId, setConfigHostId] = useState<string | null>(null);
|
||||||
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const [configForm, setConfigForm] = useState({
|
||||||
|
inspection_enabled: true,
|
||||||
|
catalog_id: "",
|
||||||
|
tier: "",
|
||||||
|
inspection_interval: "" as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const [extMap, catList, results, stats] = await Promise.all([
|
||||||
|
inspectionDeviceApi.getAll(),
|
||||||
|
inspectionCatalogApi.list(),
|
||||||
|
inspectionResultApi.getLatestAll(),
|
||||||
|
inspectionAlertApi.getStats(),
|
||||||
|
]);
|
||||||
|
setExtensions(extMap || {});
|
||||||
|
setCatalogs(Array.isArray(catList) ? catList : []);
|
||||||
|
if (Array.isArray(results)) {
|
||||||
|
const map: Record<string, InspectionResult> = {};
|
||||||
|
results.forEach((r) => {
|
||||||
|
if (r.device_id) map[r.device_id] = r;
|
||||||
|
});
|
||||||
|
setLatestResults(map);
|
||||||
|
}
|
||||||
|
setAlertStats(stats);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[InspectionCenter] 加载失败:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 构建设备行数据
|
||||||
|
const deviceRows = useMemo<DeviceRow[]>(() => {
|
||||||
|
return (hosts || [])
|
||||||
|
.filter((h) => h && h.id)
|
||||||
|
.map((host) => {
|
||||||
|
const ext = extensions[host.id];
|
||||||
|
const catalog = catalogs.find((c) => c.id === ext?.catalog_id);
|
||||||
|
const lastResult = latestResults[host.id];
|
||||||
|
const lastStatus: "success" | "failed" | "never" = lastResult
|
||||||
|
? lastResult.status === "success"
|
||||||
|
? "success"
|
||||||
|
: "failed"
|
||||||
|
: "never";
|
||||||
|
|
||||||
|
// 计算有效周期
|
||||||
|
let interval = 5;
|
||||||
|
let intervalLabel = "5 分钟(系统默认)";
|
||||||
|
if (ext?.inspection_interval) {
|
||||||
|
interval = ext.inspection_interval;
|
||||||
|
intervalLabel = `${interval} 分钟(自定义)`;
|
||||||
|
} else if (ext?.tier) {
|
||||||
|
const tierDefault = ext.tier === "core" ? 5 : ext.tier === "aggregation" ? 10 : 15;
|
||||||
|
interval = tierDefault;
|
||||||
|
intervalLabel = `${tierDefault} 分钟(${TIER_LABELS[ext.tier] || ext.tier}默认)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostId: host.id,
|
||||||
|
hostName: host.label || host.hostname || host.address || "未命名",
|
||||||
|
hostAddress: host.address || "",
|
||||||
|
hostPlatform: host.platform || "ssh",
|
||||||
|
catalogId: ext?.catalog_id || "",
|
||||||
|
catalogName: catalog?.name || ext?.catalog_id || "未配置",
|
||||||
|
tier: ext?.tier || "",
|
||||||
|
enabled: ext?.inspection_enabled || false,
|
||||||
|
interval,
|
||||||
|
intervalLabel,
|
||||||
|
lastStatus,
|
||||||
|
lastTime: lastResult?.timestamp || null,
|
||||||
|
lastResult,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [hosts, extensions, catalogs, latestResults]);
|
||||||
|
|
||||||
|
// 筛选
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
return deviceRows.filter((row) => {
|
||||||
|
if (searchText) {
|
||||||
|
const s = searchText.toLowerCase();
|
||||||
|
if (
|
||||||
|
!row.hostName.toLowerCase().includes(s) &&
|
||||||
|
!row.hostAddress.toLowerCase().includes(s) &&
|
||||||
|
!row.catalogName.toLowerCase().includes(s)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filterCatalog !== "all" && row.catalogId !== filterCatalog) return false;
|
||||||
|
if (filterTier !== "all" && row.tier !== filterTier) return false;
|
||||||
|
if (filterStatus === "enabled" && !row.enabled) return false;
|
||||||
|
if (filterStatus === "disabled" && row.enabled) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [deviceRows, searchText, filterCatalog, filterTier, filterStatus]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = deviceRows.length;
|
||||||
|
const enabled = deviceRows.filter((r) => r.enabled).length;
|
||||||
|
const success = deviceRows.filter((r) => r.lastStatus === "success").length;
|
||||||
|
const failed = deviceRows.filter((r) => r.lastStatus === "failed").length;
|
||||||
|
return { total, enabled, success, failed };
|
||||||
|
}, [deviceRows]);
|
||||||
|
|
||||||
|
const handleScanNow = async (hostId: string, hostName: string) => {
|
||||||
|
try {
|
||||||
|
const host = hosts.find((h: any) => h.id === hostId);
|
||||||
|
if (!host) {
|
||||||
|
toast.error("未找到主机配置");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造连接配置
|
||||||
|
const hostConfig: any = {
|
||||||
|
id: host.id,
|
||||||
|
label: host.label,
|
||||||
|
hostname: host.hostname,
|
||||||
|
port: host.port || 22,
|
||||||
|
username: host.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 密码
|
||||||
|
if (host.password) {
|
||||||
|
hostConfig.password = host.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 私钥
|
||||||
|
if (host.identityFileId) {
|
||||||
|
const key = keys.find((k: any) => k.id === host.identityFileId);
|
||||||
|
if (key && key.privateKey) {
|
||||||
|
hostConfig.privateKeyContent = key.privateKey;
|
||||||
|
if (key.passphrase) {
|
||||||
|
hostConfig.passphrase = key.passphrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogId = extensions[hostId]?.catalog_id || "linux_server";
|
||||||
|
|
||||||
|
toast.info(`正在对 ${hostName} 执行立即巡检...`);
|
||||||
|
const result = await inspectionExecApi.runInspection(hostId, hostConfig, catalogId);
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
toast.success(`巡检完成,耗时 ${(result.duration / 1000).toFixed(1)} 秒`);
|
||||||
|
if (result.alerts && result.alerts.length > 0) {
|
||||||
|
toast.warning(`触发 ${result.alerts.length} 条告警`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(`巡检失败: ${result?.error || "未知错误"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => loadData(), 1000);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("巡检触发失败: " + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = async (hostId: string) => {
|
||||||
|
setDetailHostId(hostId);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await inspectionResultApi.getLatestByDevice(hostId, 20);
|
||||||
|
setDetailResults(Array.isArray(results) ? results : []);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[InspectionCenter] 加载详情失败:", e);
|
||||||
|
setDetailResults([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开配置弹窗
|
||||||
|
const openConfig = (row: DeviceRow) => {
|
||||||
|
setConfigHostId(row.hostId);
|
||||||
|
setConfigForm({
|
||||||
|
inspection_enabled: row.enabled,
|
||||||
|
catalog_id: row.catalogId || "linux",
|
||||||
|
tier: row.tier || "access",
|
||||||
|
inspection_interval: extensions[row.hostId]?.inspection_interval?.toString() || "",
|
||||||
|
});
|
||||||
|
setConfigOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
const saveConfig = async () => {
|
||||||
|
if (!configHostId) return;
|
||||||
|
setConfigSaving(true);
|
||||||
|
try {
|
||||||
|
const data: any = {
|
||||||
|
inspection_enabled: configForm.inspection_enabled,
|
||||||
|
catalog_id: configForm.catalog_id,
|
||||||
|
tier: configForm.tier || null,
|
||||||
|
inspection_interval: configForm.inspection_interval
|
||||||
|
? parseInt(configForm.inspection_interval, 10)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
await inspectionDeviceApi.upsert(configHostId, data);
|
||||||
|
toast.success("配置已保存");
|
||||||
|
setConfigOpen(false);
|
||||||
|
loadData();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error("保存失败: " + e.message);
|
||||||
|
} finally {
|
||||||
|
setConfigSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (ts: number | null) => {
|
||||||
|
if (!ts) return "-";
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number | undefined) => {
|
||||||
|
if (!ms) return "-";
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: "success" | "failed" | "never") => {
|
||||||
|
switch (status) {
|
||||||
|
case "success":
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-emerald-500 bg-emerald-500/10 border-emerald-500/30">
|
||||||
|
<CheckCircle2 size={11} className="mr-1" /> 成功
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "failed":
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-red-500 bg-red-500/10 border-red-500/30">
|
||||||
|
<XCircle size={11} className="mr-1" /> 失败
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
<Clock size={11} className="mr-1" /> 未执行
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* 顶部标题栏 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/60">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Activity size={18} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">巡检中心</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
所有主机巡检状态概览与执行记录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadData} disabled={refreshing}>
|
||||||
|
<RefreshCw size={14} className={`mr-2 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 px-6 py-4">
|
||||||
|
<div className="p-4 rounded-lg border border-border/60 bg-card">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
|
<Server size={14} />
|
||||||
|
设备总数
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold">{stats.total}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
已启用巡检: {stats.enabled}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-border/60 bg-card">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
|
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||||
|
巡检成功
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-emerald-500">{stats.success}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
上次巡检结果
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-border/60 bg-card">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
|
<XCircle size={14} className="text-red-500" />
|
||||||
|
巡检失败
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-red-500">{stats.failed}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
需关注设备
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg border border-border/60 bg-card">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||||
|
<AlertTriangle size={14} className="text-orange-500" />
|
||||||
|
活跃告警
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-orange-500">
|
||||||
|
{(alertStats?.critical || 0) + (alertStats?.warning || 0) + (alertStats?.info || 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
严重 {alertStats?.critical || 0} / 警告 {alertStats?.warning || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/40 bg-muted/20">
|
||||||
|
<div className="relative flex-1 max-w-xs">
|
||||||
|
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索主机名、IP、设备类型..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">类型:</span>
|
||||||
|
<Select value={filterCatalog} onValueChange={setFilterCatalog}>
|
||||||
|
<SelectTrigger className="w-32 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="全部" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部类型</SelectItem>
|
||||||
|
{catalogs.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">分级:</span>
|
||||||
|
<Select value={filterTier} onValueChange={setFilterTier}>
|
||||||
|
<SelectTrigger className="w-24 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="全部" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部分级</SelectItem>
|
||||||
|
<SelectItem value="core">核心级</SelectItem>
|
||||||
|
<SelectItem value="aggregation">重要级</SelectItem>
|
||||||
|
<SelectItem value="access">一般</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">状态:</span>
|
||||||
|
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||||
|
<SelectTrigger className="w-24 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="全部" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部</SelectItem>
|
||||||
|
<SelectItem value="enabled">已启用</SelectItem>
|
||||||
|
<SelectItem value="disabled">已停用</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备列表 */}
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
||||||
|
) : filteredRows.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-16">
|
||||||
|
<Server size={40} className="mx-auto mb-3 opacity-40" />
|
||||||
|
<p>暂无符合条件的设备</p>
|
||||||
|
<p className="text-sm mt-1">请在主机管理中添加设备并配置巡检</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 表头 */}
|
||||||
|
<div className="grid grid-cols-12 gap-3 px-4 py-2 text-xs text-muted-foreground font-medium">
|
||||||
|
<div className="col-span-3">设备</div>
|
||||||
|
<div className="col-span-2">设备类型</div>
|
||||||
|
<div className="col-span-1">分级</div>
|
||||||
|
<div className="col-span-2">巡检周期</div>
|
||||||
|
<div className="col-span-1">状态</div>
|
||||||
|
<div className="col-span-1">上次时间</div>
|
||||||
|
<div className="col-span-2 text-right">操作</div>
|
||||||
|
</div>
|
||||||
|
{filteredRows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.hostId}
|
||||||
|
className="grid grid-cols-12 gap-3 items-center p-3 border border-border/60 rounded-lg hover:bg-accent/30 transition-colors cursor-pointer"
|
||||||
|
onClick={() => openDetail(row.hostId)}
|
||||||
|
>
|
||||||
|
<div className="col-span-3 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<Server size={13} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-sm truncate">{row.hostName}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{row.hostAddress}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm">{row.catalogName}</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
{row.tier ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{TIER_LABELS[row.tier] || row.tier}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-xs text-muted-foreground">
|
||||||
|
{row.enabled ? row.intervalLabel : "未启用"}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">{getStatusBadge(row.lastStatus)}</div>
|
||||||
|
<div className="col-span-1 text-xs text-muted-foreground">
|
||||||
|
{formatTime(row.lastTime)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openConfig(row);
|
||||||
|
}}
|
||||||
|
title="巡检配置"
|
||||||
|
>
|
||||||
|
<Settings size={13} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openDetail(row.hostId);
|
||||||
|
}}
|
||||||
|
title="巡检记录"
|
||||||
|
>
|
||||||
|
<ListIcon size={13} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={!row.enabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleScanNow(row.hostId, row.hostName);
|
||||||
|
}}
|
||||||
|
title="立即巡检"
|
||||||
|
>
|
||||||
|
<Play size={13} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 巡检记录详情弹窗 */}
|
||||||
|
<Dialog
|
||||||
|
open={!!detailHostId}
|
||||||
|
onOpenChange={(open) => !open && setDetailHostId(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Activity size={16} />
|
||||||
|
巡检记录
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
({deviceRows.find((r) => r.hostId === detailHostId)?.hostName || ""})
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
||||||
|
) : detailResults.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12">
|
||||||
|
<Clock size={32} className="mx-auto mb-2 opacity-40" />
|
||||||
|
<p>暂无巡检记录</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 overflow-y-auto pr-2">
|
||||||
|
{detailResults.map((result, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-3 rounded-lg border ${
|
||||||
|
result.status === "success"
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/5"
|
||||||
|
: "border-red-500/30 bg-red-500/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{result.status === "success" ? (
|
||||||
|
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={14} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{result.status === "success" ? "巡检成功" : "巡检失败"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{formatTime(result.timestamp)}</span>
|
||||||
|
<span>耗时 {formatDuration(result.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.metrics && Object.keys(result.metrics).length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* 格式化指标(精简展示) */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
||||||
|
{Object.entries(result.metrics)
|
||||||
|
.filter(([key]) => !["load_avg", "process_count", "uptime", "load_1m", "load_5m", "load_15m"].includes(key))
|
||||||
|
.map(([key, val]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between px-2 py-1 bg-background/50 rounded"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{formatMetricLabel(key)}</span>
|
||||||
|
<span className="font-mono">{formatMetricValue(key, val)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 原始数据(可折叠) */}
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
|
||||||
|
<ChevronRight size={12} className="data-[state=open]:hidden" />
|
||||||
|
<ChevronDown size={12} className="hidden data-[state=open]:block" />
|
||||||
|
<span>查看完整数据</span>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="mt-2">
|
||||||
|
<pre className="text-xs bg-background/80 p-2 rounded border border-border/50 overflow-auto max-h-40 font-mono">
|
||||||
|
{JSON.stringify(result.metrics, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.error_message && (
|
||||||
|
<div className="mt-2 p-2 bg-red-500/10 rounded text-xs text-red-500 font-mono">
|
||||||
|
{result.error_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 配置弹窗 */}
|
||||||
|
<Dialog open={configOpen} onOpenChange={setConfigOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Settings size={16} />
|
||||||
|
巡检配置
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
({deviceRows.find((r) => r.hostId === configHostId)?.hostName || ""})
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* 启用开关 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">启用自动巡检</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
开启后将按周期自动执行巡检
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={configForm.inspection_enabled}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setConfigForm({ ...configForm, inspection_enabled: v })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备类型 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>设备类型</Label>
|
||||||
|
<Select
|
||||||
|
value={configForm.catalog_id}
|
||||||
|
onValueChange={(v) => setConfigForm({ ...configForm, catalog_id: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{catalogs.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备分级 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>设备分级</Label>
|
||||||
|
<Select
|
||||||
|
value={configForm.tier || ""}
|
||||||
|
onValueChange={(v) => setConfigForm({ ...configForm, tier: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择分级" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="core">核心级(默认 5 分钟)</SelectItem>
|
||||||
|
<SelectItem value="aggregation">重要级(默认 10 分钟)</SelectItem>
|
||||||
|
<SelectItem value="access">一般(默认 15 分钟)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 巡检周期 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>巡检周期(分钟)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="留空则使用分级默认周期"
|
||||||
|
value={configForm.inspection_interval}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfigForm({ ...configForm, inspection_interval: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
优先级:自定义周期 → 分级默认 → 系统默认(5 分钟)
|
||||||
|
</p>
|
||||||
|
<div className="p-2 bg-muted/30 rounded text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">有效周期:</span>
|
||||||
|
{configForm.inspection_interval
|
||||||
|
? `${configForm.inspection_interval} 分钟(自定义)`
|
||||||
|
: configForm.tier
|
||||||
|
? `${configForm.tier === "core" ? 5 : configForm.tier === "aggregation" ? 10 : 15} 分钟(${TIER_LABELS[configForm.tier] || configForm.tier}默认)`
|
||||||
|
: "5 分钟(系统默认)"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setConfigOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveConfig} disabled={configSaving}>
|
||||||
|
{configSaving ? "保存中..." : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 工具函数 ----------
|
||||||
|
|
||||||
|
function formatMetricLabel(key: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
cpu_usage: "CPU 使用率",
|
||||||
|
memory_usage: "内存使用率",
|
||||||
|
disk_usage: "磁盘使用率",
|
||||||
|
load_1m: "负载 (1分钟)",
|
||||||
|
load_5m: "负载 (5分钟)",
|
||||||
|
load_15m: "负载 (15分钟)",
|
||||||
|
online: "在线状态",
|
||||||
|
temperature: "设备温度",
|
||||||
|
power_failure_count: "电源故障数",
|
||||||
|
fan_failure_count: "风扇故障数",
|
||||||
|
port_down_count: "端口 Down 数",
|
||||||
|
port_total_count: "端口总数",
|
||||||
|
port_status: "端口状态",
|
||||||
|
power_status: "电源状态",
|
||||||
|
fan_status: "风扇状态",
|
||||||
|
};
|
||||||
|
return map[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetricValue(key: string, val: any): string {
|
||||||
|
if (val === null || val === undefined) return "-";
|
||||||
|
if (key === "online") return val ? "在线" : "离线";
|
||||||
|
if (key === "cpu_usage" || key === "memory_usage") {
|
||||||
|
return `${val}%`;
|
||||||
|
}
|
||||||
|
if (key === "temperature") return `${val}°C`;
|
||||||
|
// disk_usage 是数组 { filesystem, usage_percent, mount }
|
||||||
|
if (key === "disk_usage") {
|
||||||
|
if (Array.isArray(val) && val.length > 0) {
|
||||||
|
return val.map((d: any) => `${d.mount || d.filesystem}: ${d.usage_percent}%`).join(" | ");
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
// 网络设备: port_status { up_count, down_count, ports: [{name, phy}] }
|
||||||
|
if (key === "port_status") {
|
||||||
|
if (typeof val !== "object") return String(val);
|
||||||
|
const up = val.up_count ?? 0;
|
||||||
|
const down = val.down_count ?? 0;
|
||||||
|
const total = up + down;
|
||||||
|
// 简写端口名: GigabitEthernet0/0/1 → GE0/0/1, TenGigE → XGE 等
|
||||||
|
const shortName = (n: string) => n
|
||||||
|
.replace(/GigabitEthernet/g, "GE")
|
||||||
|
.replace(/TenGigabitEthernet/g, "XGE")
|
||||||
|
.replace(/TwentyFiveGigE/g, "25GE")
|
||||||
|
.replace(/FortyGigE/g, "40GE")
|
||||||
|
.replace(/HundredGigE/g, "100GE")
|
||||||
|
.replace(/Ethernet/g, "Eth");
|
||||||
|
if (Array.isArray(val.ports) && val.ports.length > 0) {
|
||||||
|
const upList = val.ports.filter((p: any) => p.phy === "up").map((p: any) => shortName(p.name));
|
||||||
|
const downList = val.ports.filter((p: any) => p.phy === "down").map((p: any) => shortName(p.name));
|
||||||
|
const parts: string[] = [`${up}/${total} UP`];
|
||||||
|
if (upList.length > 0) parts.push(`↑${upList.join(",")}`);
|
||||||
|
if (downList.length > 0 && downList.length <= 12) parts.push(`↓${downList.join(",")}`);
|
||||||
|
else if (downList.length > 12) parts.push(`↓${downList.length}个down`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
return `${up}/${total} UP`;
|
||||||
|
}
|
||||||
|
// 网络设备: power_status / fan_status { normal_count, abnormal_count }
|
||||||
|
if (key === "power_status" || key === "fan_status") {
|
||||||
|
if (typeof val !== "object") return String(val);
|
||||||
|
const normal = val.normal_count ?? 0;
|
||||||
|
const abnormal = val.abnormal_count ?? 0;
|
||||||
|
if (abnormal > 0) return `⚠ ${abnormal}异常 / ${normal}正常`;
|
||||||
|
return `${normal}个正常`;
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InspectionCenterPage;
|
||||||
38
components/inspection/InspectionErrorBoundary.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简易 ErrorBoundary,用于包裹巡检相关组件,
|
||||||
|
* 防止组件内部错误导致整个 AsidePanel 空白。
|
||||||
|
*/
|
||||||
|
export class InspectionErrorBoundary extends React.Component<
|
||||||
|
{ children: React.ReactNode; fallback?: React.ReactNode },
|
||||||
|
{ hasError: boolean; error: Error | null }
|
||||||
|
> {
|
||||||
|
constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error) {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
console.error("[InspectionErrorBoundary] 组件渲染错误:", error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback;
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded-md border border-destructive/30 bg-destructive/5 text-sm">
|
||||||
|
<div className="font-medium text-destructive mb-1">巡检配置加载失败</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{this.state.error?.message || "未知错误"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
456
components/monitoring/MonitoringDetailPanel.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Server,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// ============ 类型定义 ============
|
||||||
|
|
||||||
|
export type PanelType =
|
||||||
|
| 'deviceList' // 设备列表(按状态筛选)
|
||||||
|
| 'alertList' // 告警列表(按级别筛选)
|
||||||
|
| 'deviceDetail' // 单设备详情
|
||||||
|
| 'taskList'; // 巡检任务列表(按状态筛选)
|
||||||
|
|
||||||
|
export interface DetailPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
panelType: PanelType;
|
||||||
|
panelTitle: string;
|
||||||
|
// 设备列表
|
||||||
|
deviceList?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ip?: string;
|
||||||
|
type: string;
|
||||||
|
status: 'online' | 'offline' | 'warning' | 'critical';
|
||||||
|
cpu?: number;
|
||||||
|
memory?: number;
|
||||||
|
lastUpdate?: number;
|
||||||
|
alertCount?: number;
|
||||||
|
}>;
|
||||||
|
// 告警列表
|
||||||
|
alertList?: Array<{
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
level: 'critical' | 'warning' | 'info';
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
metric?: string;
|
||||||
|
metricValue?: string;
|
||||||
|
threshold?: string;
|
||||||
|
}>;
|
||||||
|
// 设备详情
|
||||||
|
deviceDetail?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ip?: string;
|
||||||
|
type: string;
|
||||||
|
status: 'online' | 'offline' | 'warning' | 'critical';
|
||||||
|
cpu?: number;
|
||||||
|
memory?: number;
|
||||||
|
disk?: Array<{ mount: string; usage: number }>;
|
||||||
|
lastUpdate?: number;
|
||||||
|
alerts?: Array<{
|
||||||
|
id: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
// 任务列表
|
||||||
|
taskList?: Array<{
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
ip: string;
|
||||||
|
status: 'running' | 'success' | 'failed' | 'pending';
|
||||||
|
lastScanAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
interval?: number;
|
||||||
|
}>;
|
||||||
|
// 点击设备回调
|
||||||
|
onDeviceClick?: (deviceId: string) => void;
|
||||||
|
// 点击告警回调
|
||||||
|
onAlertClick?: (alertId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 辅助函数 ============
|
||||||
|
|
||||||
|
const statusColors: Record<string, { bg: string; text: string; dot: string; label: string }> = {
|
||||||
|
online: { bg: 'bg-emerald-500/15', text: 'text-emerald-400', dot: 'bg-emerald-500', label: '在线' },
|
||||||
|
offline: { bg: 'bg-slate-500/15', text: 'text-slate-400', dot: 'bg-slate-500', label: '离线' },
|
||||||
|
warning: { bg: 'bg-amber-500/15', text: 'text-amber-400', dot: 'bg-amber-500', label: '警告' },
|
||||||
|
critical: { bg: 'bg-red-500/15', text: 'text-red-400', dot: 'bg-red-500', label: '严重' },
|
||||||
|
running: { bg: 'bg-sky-500/15', text: 'text-sky-400', dot: 'bg-sky-500', label: '执行中' },
|
||||||
|
success: { bg: 'bg-emerald-500/15', text: 'text-emerald-400', dot: 'bg-emerald-500', label: '成功' },
|
||||||
|
failed: { bg: 'bg-red-500/15', text: 'text-red-400', dot: 'bg-red-500', label: '失败' },
|
||||||
|
pending: { bg: 'bg-slate-500/15', text: 'text-slate-400', dot: 'bg-slate-500', label: '待执行' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelColors: Record<string, { bg: string; text: string; dot: string; label: string }> = {
|
||||||
|
critical: { bg: 'bg-red-500/15', text: 'text-red-400', dot: 'bg-red-500', label: '严重' },
|
||||||
|
major: { bg: 'bg-orange-500/15', text: 'text-orange-400', dot: 'bg-orange-500', label: '重要' },
|
||||||
|
warning: { bg: 'bg-amber-500/15', text: 'text-amber-400', dot: 'bg-amber-500', label: '警告' },
|
||||||
|
minor: { bg: 'bg-yellow-500/15', text: 'text-yellow-400', dot: 'bg-yellow-500', label: '次要' },
|
||||||
|
info: { bg: 'bg-sky-500/15', text: 'text-sky-400', dot: 'bg-sky-500', label: '提示' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRelativeTime(ts?: number): string {
|
||||||
|
if (!ts) return '-';
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
if (diff < 60000) return '刚刚';
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
|
||||||
|
return `${Math.floor(diff / 86400000)} 天前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 组件 ============
|
||||||
|
|
||||||
|
export default function MonitoringDetailPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
panelType,
|
||||||
|
panelTitle,
|
||||||
|
deviceList = [],
|
||||||
|
alertList = [],
|
||||||
|
deviceDetail,
|
||||||
|
taskList = [],
|
||||||
|
onDeviceClick,
|
||||||
|
}: DetailPanelProps) {
|
||||||
|
|
||||||
|
// ============ 设备列表渲染 ============
|
||||||
|
const renderDeviceList = () => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deviceList.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">暂无数据</div>
|
||||||
|
)}
|
||||||
|
{deviceList.map((d) => {
|
||||||
|
const sc = statusColors[d.status] || statusColors.online;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
className="p-3 rounded-lg border cursor-pointer transition-all hover:border-cyan-500/40 hover:bg-cyan-500/5"
|
||||||
|
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}
|
||||||
|
onClick={() => onDeviceClick?.(d.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${sc.dot} flex-shrink-0`} />
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{d.name}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
{d.ip && <span>{d.ip}</span>}
|
||||||
|
<span>{d.type}</span>
|
||||||
|
<span className={`${sc.text}`}>{sc.label}</span>
|
||||||
|
</div>
|
||||||
|
{(d.cpu !== undefined || d.memory !== undefined) && (
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs">
|
||||||
|
{d.cpu !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Cpu className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">CPU</span>
|
||||||
|
<span className={d.cpu > 80 ? 'text-red-400' : d.cpu > 60 ? 'text-amber-400' : 'text-emerald-400'}>
|
||||||
|
{d.cpu}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{d.memory !== undefined && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<HardDrive className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">内存</span>
|
||||||
|
<span className={d.memory > 85 ? 'text-red-400' : d.memory > 70 ? 'text-amber-400' : 'text-emerald-400'}>
|
||||||
|
{d.memory}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============ 告警列表渲染 ============
|
||||||
|
const renderAlertList = () => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{alertList.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-emerald-400 text-sm">暂无告警</div>
|
||||||
|
)}
|
||||||
|
{alertList.map((a) => {
|
||||||
|
const lc = levelColors[a.level] || levelColors.info;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className="p-3 rounded-lg border transition-all"
|
||||||
|
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${lc.dot} flex-shrink-0 mt-0.5`} />
|
||||||
|
<span className={`text-xs font-medium ${lc.text} flex-shrink-0`}>{lc.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0">{a.time}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-foreground mb-1">{a.message}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
设备:<span className="text-cyan-400">{a.deviceName}</span>
|
||||||
|
{a.metric && a.metricValue && (
|
||||||
|
<span className="ml-3">
|
||||||
|
{a.metric}: {a.metricValue}
|
||||||
|
{a.threshold && <span className="text-muted-foreground"> (阈值 {a.threshold})</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============ 设备详情渲染 ============
|
||||||
|
const renderDeviceDetail = () => {
|
||||||
|
if (!deviceDetail) return null;
|
||||||
|
const sc = statusColors[deviceDetail.status] || statusColors.online;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div className="p-3 rounded-lg border" style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Server className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-sm font-medium">基本信息</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-0.5">设备名称</div>
|
||||||
|
<div className="text-foreground font-medium">{deviceDetail.name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-0.5">设备类型</div>
|
||||||
|
<div className="text-foreground">{deviceDetail.type}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-0.5">IP 地址</div>
|
||||||
|
<div className="text-foreground font-mono">{deviceDetail.ip || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground mb-0.5">运行状态</div>
|
||||||
|
<div className={`${sc.text} font-medium`}>{sc.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 资源使用 */}
|
||||||
|
<div className="p-3 rounded-lg border" style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Activity className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="text-sm font-medium">资源使用</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* CPU */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-muted-foreground">CPU 使用率</span>
|
||||||
|
<span className={deviceDetail.cpu && deviceDetail.cpu > 80 ? 'text-red-400' : 'text-foreground'}>
|
||||||
|
{deviceDetail.cpu ?? '-'}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${deviceDetail.cpu ?? 0}%`,
|
||||||
|
background: deviceDetail.cpu && deviceDetail.cpu > 80 ? '#f87171'
|
||||||
|
: deviceDetail.cpu && deviceDetail.cpu > 60 ? '#fbbf24' : '#34d399',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 内存 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-muted-foreground">内存使用率</span>
|
||||||
|
<span className={deviceDetail.memory && deviceDetail.memory > 85 ? 'text-red-400' : 'text-foreground'}>
|
||||||
|
{deviceDetail.memory ?? '-'}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${deviceDetail.memory ?? 0}%`,
|
||||||
|
background: deviceDetail.memory && deviceDetail.memory > 85 ? '#f87171'
|
||||||
|
: deviceDetail.memory && deviceDetail.memory > 70 ? '#fbbf24' : '#34d399',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 磁盘 */}
|
||||||
|
{deviceDetail.disk && deviceDetail.disk.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-muted-foreground">磁盘使用率</div>
|
||||||
|
{deviceDetail.disk.map((d, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-muted-foreground font-mono">{d.mount}</span>
|
||||||
|
<span className={d.usage > 90 ? 'text-red-400' : d.usage > 80 ? 'text-amber-400' : 'text-foreground'}>
|
||||||
|
{d.usage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${d.usage}%`,
|
||||||
|
background: d.usage > 90 ? '#f87171' : d.usage > 80 ? '#fbbf24' : '#34d399',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 更新时间 */}
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
最后更新:{formatRelativeTime(deviceDetail.lastUpdate)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 告警历史 */}
|
||||||
|
{deviceDetail.alerts && deviceDetail.alerts.length > 0 && (
|
||||||
|
<div className="p-3 rounded-lg border" style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-sm font-medium">告警记录</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deviceDetail.alerts.map((a) => {
|
||||||
|
const lc = levelColors[a.level] || levelColors.info;
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="flex items-start gap-2 text-xs">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${lc.dot} flex-shrink-0 mt-1`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-foreground">{a.message}</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5">{a.time}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ 任务列表渲染 ============
|
||||||
|
const renderTaskList = () => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{taskList.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">暂无数据</div>
|
||||||
|
)}
|
||||||
|
{taskList.map((t) => {
|
||||||
|
const sc = statusColors[t.status] || statusColors.pending;
|
||||||
|
const durationStr = t.durationMs ? `${(t.durationMs / 1000).toFixed(1)}s` : null;
|
||||||
|
const intervalStr = t.interval ? `${t.interval}秒` : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.deviceId}
|
||||||
|
className="p-3 rounded-lg border transition-all"
|
||||||
|
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{t.status === 'running' && <Loader2 className="w-3.5 h-3.5 text-sky-400 animate-spin flex-shrink-0" />}
|
||||||
|
{t.status === 'success' && <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0" />}
|
||||||
|
{t.status === 'failed' && <XCircle className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />}
|
||||||
|
{t.status === 'pending' && <Clock className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />}
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{t.deviceName}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium ${sc.text} flex-shrink-0`}>{sc.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono mb-1">{t.ip}</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
|
||||||
|
{t.lastScanAt && <span>上次 {formatRelativeTime(t.lastScanAt)}</span>}
|
||||||
|
{durationStr && <span>耗时 {durationStr}</span>}
|
||||||
|
{intervalStr && <span>间隔 {intervalStr}</span>}
|
||||||
|
</div>
|
||||||
|
{t.errorMessage && (
|
||||||
|
<div className="mt-2 text-xs text-red-400 bg-red-500/10 p-2 rounded">
|
||||||
|
{t.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============ 主渲染 ============
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 遮罩 */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-black/50 z-40 transition-opacity ${
|
||||||
|
open ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* 面板 */}
|
||||||
|
<div
|
||||||
|
className={`absolute right-0 top-0 bottom-0 z-50 transition-transform duration-300 ease-out flex flex-col ${
|
||||||
|
open ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: '420px',
|
||||||
|
background: '#0f172a',
|
||||||
|
borderLeft: '1px solid rgba(148,163,184,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||||
|
style={{ borderBottom: '1px solid rgba(148,163,184,0.15)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-1 h-5 rounded-full"
|
||||||
|
style={{ background: 'linear-gradient(180deg, #06b6d4, #3b82f6)' }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">{panelTitle}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded hover:bg-slate-700/50 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
{panelType === 'deviceList' && renderDeviceList()}
|
||||||
|
{panelType === 'alertList' && renderAlertList()}
|
||||||
|
{panelType === 'deviceDetail' && renderDeviceDetail()}
|
||||||
|
{panelType === 'taskList' && renderTaskList()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
2409
components/monitoring/MonitoringTopologyView.tsx
Normal file
637
components/monitoring/deviceModels.ts
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
/**
|
||||||
|
* 精细 3D 设备模型工厂
|
||||||
|
* 参考 ITYUNWEI modelFactory.ts 设计
|
||||||
|
*/
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export type DeviceStatus = 'normal' | 'warning' | 'critical' | 'offline';
|
||||||
|
export type DeviceType = 'server' | 'switch' | 'router' | 'firewall' | 'storage' | 'workstation';
|
||||||
|
|
||||||
|
const statusColors: Record<DeviceStatus, number> = {
|
||||||
|
normal: 0x10b981,
|
||||||
|
warning: 0xf59e0b,
|
||||||
|
critical: 0xef4444,
|
||||||
|
offline: 0x6b7280,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 不同设备类型的默认发光颜色
|
||||||
|
const typeColors: Record<DeviceType, number> = {
|
||||||
|
server: 0x60a5fa, // 蓝色 - 服务器
|
||||||
|
switch: 0x22d3ee, // 青色 - 交换机
|
||||||
|
router: 0xa78bfa, // 紫色 - 路由器
|
||||||
|
firewall: 0xf87171, // 红色 - 防火墙
|
||||||
|
storage: 0xfbbf24, // 黄色 - 存储
|
||||||
|
workstation: 0x34d399, // 绿色 - 工作站
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 创建设备基础材质:金属外壳 + 状态发光 */
|
||||||
|
function createBodyMaterial(status: DeviceStatus, type?: DeviceType): THREE.MeshStandardMaterial {
|
||||||
|
// 根据状态和类型决定发光颜色
|
||||||
|
let emissiveColor = statusColors[status];
|
||||||
|
if (status === 'normal' && type) {
|
||||||
|
emissiveColor = typeColors[type] || emissiveColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intensity = status === 'normal' ? 0.5 : status === 'warning' ? 0.7 : status === 'critical' ? 1.0 : 0.2;
|
||||||
|
|
||||||
|
return new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x2c3e5a,
|
||||||
|
metalness: 0.55,
|
||||||
|
roughness: 0.35,
|
||||||
|
emissive: emissiveColor,
|
||||||
|
emissiveIntensity: intensity,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LED 指示灯材质 */
|
||||||
|
function createLedMaterial(color: number, intensity = 0.8): THREE.MeshStandardMaterial {
|
||||||
|
return new THREE.MeshStandardMaterial({
|
||||||
|
color,
|
||||||
|
emissive: color,
|
||||||
|
emissiveIntensity: intensity,
|
||||||
|
metalness: 0.2,
|
||||||
|
roughness: 0.3,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前面板塑料材质 */
|
||||||
|
function createPanelMaterial(): THREE.MeshStandardMaterial {
|
||||||
|
return new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x334155,
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.6,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 端口金属材质 */
|
||||||
|
function createPortMaterial(): THREE.MeshStandardMaterial {
|
||||||
|
return new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x0f172a,
|
||||||
|
metalness: 0.9,
|
||||||
|
roughness: 0.2,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1. 服务器 Server - 机架式服务器
|
||||||
|
// ============================================================
|
||||||
|
export function createServerModel(status: DeviceStatus, scale = 1.5): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const bodyMat = createBodyMaterial(status, 'server');
|
||||||
|
const panelMat = createPanelMaterial();
|
||||||
|
|
||||||
|
// 主体机箱(扁平长方体)
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.6, scale * 0.5, scale * 1.0),
|
||||||
|
bodyMat
|
||||||
|
);
|
||||||
|
body.userData.isBody = true;
|
||||||
|
group.add(body);
|
||||||
|
|
||||||
|
// 前面板
|
||||||
|
const frontPanel = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.62, scale * 0.46, scale * 0.04),
|
||||||
|
panelMat
|
||||||
|
);
|
||||||
|
frontPanel.position.z = scale * 0.52;
|
||||||
|
group.add(frontPanel);
|
||||||
|
|
||||||
|
// 前面板 - 硬盘槽位(4个)
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const tray = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.3, scale * 0.35, scale * 0.02),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.5, roughness: 0.5 })
|
||||||
|
);
|
||||||
|
tray.position.set(-scale * 0.55 + i * scale * 0.37, 0, scale * 0.54);
|
||||||
|
group.add(tray);
|
||||||
|
|
||||||
|
// 硬盘槽位 LED 灯
|
||||||
|
const ledColor = i === 0 ? statusColors.normal : i === 1 ? statusColors.warning : 0x475569;
|
||||||
|
const led = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.02, 8, 8),
|
||||||
|
createLedMaterial(ledColor, i < 2 ? 1.0 : 0.5)
|
||||||
|
);
|
||||||
|
led.position.set(-scale * 0.55 + i * scale * 0.37, scale * 0.12, scale * 0.56);
|
||||||
|
group.add(led);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务器状态 LED 灯(右上角)
|
||||||
|
const powerLed = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.025, 10, 10),
|
||||||
|
createLedMaterial(statusColors[status], 1.2)
|
||||||
|
);
|
||||||
|
powerLed.position.set(scale * 0.68, scale * 0.18, scale * 0.56);
|
||||||
|
group.add(powerLed);
|
||||||
|
|
||||||
|
const statusLed = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.02, 10, 10),
|
||||||
|
createLedMaterial(0x22d3ee, 1.0)
|
||||||
|
);
|
||||||
|
statusLed.position.set(scale * 0.72, scale * 0.18, scale * 0.56);
|
||||||
|
group.add(statusLed);
|
||||||
|
|
||||||
|
// 顶部散热格栅
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const fin = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.2, 0.01, 0.03),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.8, roughness: 0.3 })
|
||||||
|
);
|
||||||
|
fin.position.set(0, scale * 0.26, -scale * 0.3 + i * scale * 0.12);
|
||||||
|
group.add(fin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2. 交换机 Switch - 扁平机架式 + 端口阵列
|
||||||
|
// ============================================================
|
||||||
|
export function createSwitchModel(status: DeviceStatus, scale = 1.8): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const bodyMat = createBodyMaterial(status, 'switch');
|
||||||
|
const panelMat = createPanelMaterial();
|
||||||
|
const portMat = createPortMaterial();
|
||||||
|
const earMat = new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.8, roughness: 0.3 });
|
||||||
|
|
||||||
|
// 主体机箱(非常扁平)
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.8, scale * 0.28, scale * 0.8),
|
||||||
|
bodyMat
|
||||||
|
);
|
||||||
|
body.userData.isBody = true;
|
||||||
|
group.add(body);
|
||||||
|
|
||||||
|
// 前面板
|
||||||
|
const frontPanel = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.82, scale * 0.24, scale * 0.03),
|
||||||
|
panelMat
|
||||||
|
);
|
||||||
|
frontPanel.position.z = scale * 0.41;
|
||||||
|
group.add(frontPanel);
|
||||||
|
|
||||||
|
// 机架耳
|
||||||
|
const leftEar = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.12, scale * 0.22, scale * 0.15),
|
||||||
|
earMat
|
||||||
|
);
|
||||||
|
leftEar.position.set(-scale * 0.97, 0, 0);
|
||||||
|
group.add(leftEar);
|
||||||
|
const rightEar = leftEar.clone();
|
||||||
|
rightEar.position.x = scale * 0.97;
|
||||||
|
group.add(rightEar);
|
||||||
|
|
||||||
|
// 端口阵列(24口,2行 x 12列)
|
||||||
|
const portSize = scale * 0.06;
|
||||||
|
const portGap = scale * 0.08;
|
||||||
|
for (let row = 0; row < 2; row++) {
|
||||||
|
for (let col = 0; col < 12; col++) {
|
||||||
|
const port = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(portSize, portSize, scale * 0.05),
|
||||||
|
portMat
|
||||||
|
);
|
||||||
|
const startX = -scale * 0.72 + col * portGap;
|
||||||
|
const y = row === 0 ? scale * 0.06 : -scale * 0.06;
|
||||||
|
port.position.set(startX, y, scale * 0.44);
|
||||||
|
group.add(port);
|
||||||
|
|
||||||
|
// 端口上方的小 LED(亮绿色)
|
||||||
|
const portLed = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.012, 6, 6),
|
||||||
|
createLedMaterial(0x22c55e, 0.8)
|
||||||
|
);
|
||||||
|
portLed.position.set(startX, y + scale * 0.05, scale * 0.47);
|
||||||
|
group.add(portLed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 品牌区域
|
||||||
|
const brandArea = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.3, scale * 0.08, 0.035),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.5, roughness: 0.5 })
|
||||||
|
);
|
||||||
|
brandArea.position.set(-scale * 0.82, scale * 0.12, scale * 0.44);
|
||||||
|
group.add(brandArea);
|
||||||
|
|
||||||
|
// 状态 LED 灯组
|
||||||
|
const ledPositions = [
|
||||||
|
{ color: statusColors[status], x: scale * 0.82, y: scale * 0.08 },
|
||||||
|
{ color: 0x22d3ee, x: scale * 0.87, y: scale * 0.08 },
|
||||||
|
{ color: 0x22c55e, x: scale * 0.82, y: -scale * 0.08 },
|
||||||
|
{ color: 0xf59e0b, x: scale * 0.87, y: -scale * 0.08 },
|
||||||
|
];
|
||||||
|
ledPositions.forEach(({ color, x, y }) => {
|
||||||
|
const led = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.018, 8, 8),
|
||||||
|
createLedMaterial(color, 1.0)
|
||||||
|
);
|
||||||
|
led.position.set(x, y, scale * 0.44);
|
||||||
|
group.add(led);
|
||||||
|
});
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3. 路由器 Router - 1U 机架式 + 天线 + 端口阵列
|
||||||
|
// ============================================================
|
||||||
|
export function createRouterModel(status: DeviceStatus, scale = 1.8): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const bodyMat = createBodyMaterial(status, 'router');
|
||||||
|
const panelMat = createPanelMaterial();
|
||||||
|
const portMat = createPortMaterial();
|
||||||
|
const earMat = new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.8, roughness: 0.3 });
|
||||||
|
|
||||||
|
// 1U 主体机箱
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.6, scale * 0.35, scale * 0.9),
|
||||||
|
bodyMat
|
||||||
|
);
|
||||||
|
body.userData.isBody = true;
|
||||||
|
group.add(body);
|
||||||
|
|
||||||
|
// 前面板
|
||||||
|
const frontPanel = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.62, scale * 0.31, scale * 0.03),
|
||||||
|
panelMat
|
||||||
|
);
|
||||||
|
frontPanel.position.z = scale * 0.47;
|
||||||
|
group.add(frontPanel);
|
||||||
|
|
||||||
|
// 机架耳
|
||||||
|
const leftEar = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.1, scale * 0.28, scale * 0.12),
|
||||||
|
earMat
|
||||||
|
);
|
||||||
|
leftEar.position.set(-scale * 0.86, 0, 0);
|
||||||
|
group.add(leftEar);
|
||||||
|
const rightEar = leftEar.clone();
|
||||||
|
rightEar.position.x = scale * 0.86;
|
||||||
|
group.add(rightEar);
|
||||||
|
|
||||||
|
// 品牌区域 + 状态 LED
|
||||||
|
const brandArea = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.22, scale * 0.07, 0.035),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.6, roughness: 0.4 })
|
||||||
|
);
|
||||||
|
brandArea.position.set(-scale * 0.65, scale * 0.1, scale * 0.49);
|
||||||
|
group.add(brandArea);
|
||||||
|
|
||||||
|
// 状态 LED 组
|
||||||
|
const routerLeds = [
|
||||||
|
{ color: statusColors[status], x: -scale * 0.38 },
|
||||||
|
{ color: 0x22d3ee, x: -scale * 0.33 },
|
||||||
|
{ color: 0x22c55e, x: -scale * 0.28 },
|
||||||
|
{ color: 0xf59e0b, x: -scale * 0.23 },
|
||||||
|
];
|
||||||
|
routerLeds.forEach(({ color, x }) => {
|
||||||
|
const led = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.018, 8, 8),
|
||||||
|
createLedMaterial(color, 1.0)
|
||||||
|
);
|
||||||
|
led.position.set(x, scale * 0.1, scale * 0.49);
|
||||||
|
group.add(led);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WAN 口(光纤口样式)
|
||||||
|
const sfpMat = new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.7, roughness: 0.3 });
|
||||||
|
const wanPort = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.12, scale * 0.14, scale * 0.06),
|
||||||
|
sfpMat
|
||||||
|
);
|
||||||
|
wanPort.position.set(-scale * 0.05, -scale * 0.02, scale * 0.5);
|
||||||
|
group.add(wanPort);
|
||||||
|
const wanRing = new THREE.Mesh(
|
||||||
|
new THREE.TorusGeometry(scale * 0.045, 0.008, 6, 16),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xfbbf24, emissive: 0xfbbf24, emissiveIntensity: 0.6, metalness: 0.5, roughness: 0.3 })
|
||||||
|
);
|
||||||
|
wanRing.rotation.x = Math.PI / 2;
|
||||||
|
wanRing.position.set(-scale * 0.05, scale * 0.08, scale * 0.53);
|
||||||
|
group.add(wanRing);
|
||||||
|
|
||||||
|
// LAN 口阵列(4个)
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const port = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.1, scale * 0.13, scale * 0.05),
|
||||||
|
portMat
|
||||||
|
);
|
||||||
|
port.position.set(scale * 0.2 + i * scale * 0.13, -scale * 0.02, scale * 0.5);
|
||||||
|
group.add(port);
|
||||||
|
|
||||||
|
const lanLed = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.012, 6, 6),
|
||||||
|
createLedMaterial(0x22c55e, 0.9)
|
||||||
|
);
|
||||||
|
lanLed.position.set(scale * 0.2 + i * scale * 0.13, -scale * 0.11, scale * 0.52);
|
||||||
|
group.add(lanLed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 天线
|
||||||
|
const antennaMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.9, roughness: 0.2 });
|
||||||
|
// 天线 1 - 左侧直立
|
||||||
|
const ant1 = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.012, 0.015, scale * 0.7, 8),
|
||||||
|
antennaMat
|
||||||
|
);
|
||||||
|
ant1.position.set(-scale * 0.55, scale * 0.5, -scale * 0.1);
|
||||||
|
group.add(ant1);
|
||||||
|
const ant1Tip = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.022, 10, 10),
|
||||||
|
createLedMaterial(0x22d3ee, 1.2)
|
||||||
|
);
|
||||||
|
ant1Tip.position.set(-scale * 0.55, scale * 0.87, -scale * 0.1);
|
||||||
|
group.add(ant1Tip);
|
||||||
|
|
||||||
|
// 天线 2 - 右侧略向后倾斜
|
||||||
|
const ant2 = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.012, 0.015, scale * 0.65, 8),
|
||||||
|
antennaMat
|
||||||
|
);
|
||||||
|
ant2.position.set(scale * 0.55, scale * 0.48, -scale * 0.15);
|
||||||
|
ant2.rotation.x = 0.15;
|
||||||
|
ant2.rotation.z = -0.1;
|
||||||
|
group.add(ant2);
|
||||||
|
const ant2Tip = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.022, 10, 10),
|
||||||
|
createLedMaterial(0x22d3ee, 1.2)
|
||||||
|
);
|
||||||
|
ant2Tip.position.set(scale * 0.52, scale * 0.83, -scale * 0.2);
|
||||||
|
group.add(ant2Tip);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 4. 防火墙 Firewall - 1U 机架式 + LCD 屏 + 盾形浮雕
|
||||||
|
// ============================================================
|
||||||
|
export function createFirewallModel(status: DeviceStatus, scale = 1.8): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const bodyMat = createBodyMaterial(status, 'firewall');
|
||||||
|
const panelMat = createPanelMaterial();
|
||||||
|
const portMat = createPortMaterial();
|
||||||
|
const earMat = new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.8, roughness: 0.3 });
|
||||||
|
|
||||||
|
// 1U 主体机箱
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.8, scale * 0.36, scale * 0.9),
|
||||||
|
bodyMat
|
||||||
|
);
|
||||||
|
body.userData.isBody = true;
|
||||||
|
group.add(body);
|
||||||
|
|
||||||
|
// 前面板
|
||||||
|
const frontPanel = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.82, scale * 0.32, scale * 0.03),
|
||||||
|
panelMat
|
||||||
|
);
|
||||||
|
frontPanel.position.z = scale * 0.48;
|
||||||
|
group.add(frontPanel);
|
||||||
|
|
||||||
|
// 机架耳
|
||||||
|
const leftEar = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.1, scale * 0.29, scale * 0.12),
|
||||||
|
earMat
|
||||||
|
);
|
||||||
|
leftEar.position.set(-scale * 0.97, 0, 0);
|
||||||
|
group.add(leftEar);
|
||||||
|
const rightEar = leftEar.clone();
|
||||||
|
rightEar.position.x = scale * 0.97;
|
||||||
|
group.add(rightEar);
|
||||||
|
|
||||||
|
// 红色安全装饰条
|
||||||
|
const redStrip = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.55, 0.018, 0.035),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xef4444,
|
||||||
|
emissive: 0xef4444,
|
||||||
|
emissiveIntensity: 0.4,
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.5,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
redStrip.position.set(0, scale * 0.15, scale * 0.5);
|
||||||
|
group.add(redStrip);
|
||||||
|
|
||||||
|
// LCD 状态显示屏
|
||||||
|
const lcdFrame = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.32, scale * 0.2, scale * 0.04),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.6, roughness: 0.4 })
|
||||||
|
);
|
||||||
|
lcdFrame.position.set(-scale * 0.55, -scale * 0.02, scale * 0.49);
|
||||||
|
group.add(lcdFrame);
|
||||||
|
|
||||||
|
// LCD 显示面(发光蓝绿色)
|
||||||
|
const lcdScreen = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.28, scale * 0.16, 0.01),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x0d9488,
|
||||||
|
emissive: 0x14b8a6,
|
||||||
|
emissiveIntensity: 0.7,
|
||||||
|
metalness: 0.2,
|
||||||
|
roughness: 0.3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
lcdScreen.position.set(-scale * 0.55, -scale * 0.02, scale * 0.52);
|
||||||
|
group.add(lcdScreen);
|
||||||
|
|
||||||
|
// 盾形浮雕
|
||||||
|
const shieldEmbossMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x6366f1,
|
||||||
|
metalness: 0.7,
|
||||||
|
roughness: 0.3,
|
||||||
|
emissive: 0x6366f1,
|
||||||
|
emissiveIntensity: 0.35,
|
||||||
|
});
|
||||||
|
const shieldTop = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(scale * 0.08, scale * 0.14, 4),
|
||||||
|
shieldEmbossMat
|
||||||
|
);
|
||||||
|
shieldTop.rotation.y = Math.PI / 4;
|
||||||
|
shieldTop.position.set(-scale * 0.18, -scale * 0.03, scale * 0.5);
|
||||||
|
group.add(shieldTop);
|
||||||
|
const shieldBody = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.12, scale * 0.14, 0.02),
|
||||||
|
shieldEmbossMat
|
||||||
|
);
|
||||||
|
shieldBody.position.set(-scale * 0.18, -scale * 0.13, scale * 0.5);
|
||||||
|
group.add(shieldBody);
|
||||||
|
|
||||||
|
// 盾心
|
||||||
|
const shieldCore = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.012, 8, 8),
|
||||||
|
createLedMaterial(0x22d3ee, 1.2)
|
||||||
|
);
|
||||||
|
shieldCore.position.set(-scale * 0.18, -scale * 0.07, scale * 0.52);
|
||||||
|
group.add(shieldCore);
|
||||||
|
|
||||||
|
// 状态 LED 组
|
||||||
|
const fwLeds = [
|
||||||
|
{ color: statusColors[status], x: scale * 0.02 },
|
||||||
|
{ color: 0xef4444, x: scale * 0.08 },
|
||||||
|
{ color: 0xf59e0b, x: scale * 0.14 },
|
||||||
|
{ color: 0x22c55e, x: scale * 0.2 },
|
||||||
|
];
|
||||||
|
fwLeds.forEach(({ color, x }) => {
|
||||||
|
const led = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.018, 8, 8),
|
||||||
|
createLedMaterial(color, 1.0)
|
||||||
|
);
|
||||||
|
led.position.set(x, scale * 0.08, scale * 0.5);
|
||||||
|
group.add(led);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SFP 口
|
||||||
|
const sfpMat = new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.7, roughness: 0.3 });
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const sfp = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.1, scale * 0.12, scale * 0.06),
|
||||||
|
sfpMat
|
||||||
|
);
|
||||||
|
sfp.position.set(scale * 0.45 + i * scale * 0.15, -scale * 0.02, scale * 0.5);
|
||||||
|
group.add(sfp);
|
||||||
|
|
||||||
|
const sfpLed = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.012, 6, 6),
|
||||||
|
createLedMaterial(0x22d3ee, 1.0)
|
||||||
|
);
|
||||||
|
sfpLed.position.set(scale * 0.45 + i * scale * 0.15, -scale * 0.12, scale * 0.52);
|
||||||
|
group.add(sfpLed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网口
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const port = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.08, scale * 0.1, scale * 0.05),
|
||||||
|
portMat
|
||||||
|
);
|
||||||
|
port.position.set(scale * 0.78 + i * scale * 0.11, -scale * 0.03, scale * 0.5);
|
||||||
|
group.add(port);
|
||||||
|
|
||||||
|
const portLed = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.01, 6, 6),
|
||||||
|
createLedMaterial(0x22c55e, 0.9)
|
||||||
|
);
|
||||||
|
portLed.position.set(scale * 0.78 + i * scale * 0.11, -scale * 0.11, scale * 0.52);
|
||||||
|
group.add(portLed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 5. 存储 Storage - 磁盘阵列
|
||||||
|
// ============================================================
|
||||||
|
export function createStorageModel(status: DeviceStatus, scale = 1.5): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const bodyMat = createBodyMaterial(status, 'storage');
|
||||||
|
const panelMat = createPanelMaterial();
|
||||||
|
|
||||||
|
// 主体大型方体
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.4, scale * 0.9, scale * 1.1),
|
||||||
|
bodyMat
|
||||||
|
);
|
||||||
|
body.userData.isBody = true;
|
||||||
|
group.add(body);
|
||||||
|
|
||||||
|
// 前面板
|
||||||
|
const frontPanel = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 1.42, scale * 0.82, scale * 0.04),
|
||||||
|
panelMat
|
||||||
|
);
|
||||||
|
frontPanel.position.z = scale * 0.57;
|
||||||
|
group.add(frontPanel);
|
||||||
|
|
||||||
|
// 磁盘槽位(3行 x 4列)
|
||||||
|
const driveW = scale * 0.28;
|
||||||
|
const driveH = scale * 0.18;
|
||||||
|
const startX = -scale * 0.55;
|
||||||
|
const startY = scale * 0.32;
|
||||||
|
|
||||||
|
for (let row = 0; row < 3; row++) {
|
||||||
|
for (let col = 0; col < 4; col++) {
|
||||||
|
const drive = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(driveW, driveH, scale * 0.025),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: row === 2 && col >= 2 ? 0x0f172a : 0x1e293b,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.5,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
drive.position.set(
|
||||||
|
startX + col * (driveW + 0.02),
|
||||||
|
startY - row * (driveH + 0.04),
|
||||||
|
scale * 0.6
|
||||||
|
);
|
||||||
|
group.add(drive);
|
||||||
|
|
||||||
|
// 磁盘 LED
|
||||||
|
const isActive = !(row === 2 && col >= 2);
|
||||||
|
const ledColor = isActive
|
||||||
|
? row === 0 && col === 0
|
||||||
|
? statusColors[status]
|
||||||
|
: 0x22c55e
|
||||||
|
: 0x475569;
|
||||||
|
const led = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.015, 8, 8),
|
||||||
|
createLedMaterial(ledColor, isActive ? 0.8 : 0.3)
|
||||||
|
);
|
||||||
|
led.position.set(
|
||||||
|
startX + col * (driveW + 0.02) - driveW * 0.35,
|
||||||
|
startY - row * (driveH + 0.04),
|
||||||
|
scale * 0.62
|
||||||
|
);
|
||||||
|
group.add(led);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态区域
|
||||||
|
const statusArea = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(scale * 0.3, scale * 0.1, scale * 0.045),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.6, roughness: 0.4 })
|
||||||
|
);
|
||||||
|
statusArea.position.set(-scale * 0.5, -scale * 0.32, scale * 0.59);
|
||||||
|
group.add(statusArea);
|
||||||
|
|
||||||
|
const stLeds = [
|
||||||
|
{ color: statusColors[status], x: -scale * 0.58 },
|
||||||
|
{ color: 0x22d3ee, x: -scale * 0.53 },
|
||||||
|
{ color: 0x22c55e, x: -scale * 0.48 },
|
||||||
|
];
|
||||||
|
stLeds.forEach(({ color, x }) => {
|
||||||
|
const led = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.02, 10, 10),
|
||||||
|
createLedMaterial(color, 1.0)
|
||||||
|
);
|
||||||
|
led.position.set(x, -scale * 0.32, scale * 0.61);
|
||||||
|
group.add(led);
|
||||||
|
});
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 统一的设备模型创建入口
|
||||||
|
// ============================================================
|
||||||
|
export function createDeviceModel(type: DeviceType, status: DeviceStatus, scale?: number): THREE.Group {
|
||||||
|
switch (type) {
|
||||||
|
case 'server':
|
||||||
|
return createServerModel(status, scale ?? 1.5);
|
||||||
|
case 'switch':
|
||||||
|
return createSwitchModel(status, scale ?? 1.8);
|
||||||
|
case 'router':
|
||||||
|
return createRouterModel(status, scale ?? 2.0);
|
||||||
|
case 'firewall':
|
||||||
|
return createFirewallModel(status, scale ?? 2.0);
|
||||||
|
case 'storage':
|
||||||
|
return createStorageModel(status, scale ?? 1.6);
|
||||||
|
case 'workstation':
|
||||||
|
// 工作站用服务器模型代替(简化版)
|
||||||
|
return createServerModel(status, scale ?? 1.4);
|
||||||
|
default:
|
||||||
|
return createServerModel(status, scale ?? 1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
569
components/monitoring/useMonitoringData.ts
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
/**
|
||||||
|
* useMonitoringData - 运行监控大屏数据 hook
|
||||||
|
*
|
||||||
|
* 数据源(全部来自 NetMesh 真实 API):
|
||||||
|
* - hosts: props 传入(vault 里的设备列表)
|
||||||
|
* - inspection.getLatestAll() → 每台设备最新巡检结果(含 metrics:cpu/memory/online)
|
||||||
|
* - inspectionAlertApi.getActive() → 活动告警列表
|
||||||
|
* - inspectionAlertApi.getStats() → 告警统计
|
||||||
|
* - inspectionRuleApi.list() → 巡检规则(用于任务状态统计)
|
||||||
|
*/
|
||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import type { Host } from '../../domain/models';
|
||||||
|
import {
|
||||||
|
inspectionResultApi,
|
||||||
|
inspectionAlertApi,
|
||||||
|
inspectionAlertRuleApi,
|
||||||
|
inspectionSchedulerApi,
|
||||||
|
inspectionDeviceApi,
|
||||||
|
type AlertRecord,
|
||||||
|
type InspectionResult,
|
||||||
|
type AlertLevel,
|
||||||
|
type AlertStats,
|
||||||
|
} from '../../infrastructure/inspection/inspectionApi';
|
||||||
|
|
||||||
|
// ===== 对外类型 =====
|
||||||
|
|
||||||
|
export interface MonitoringAlert {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
level: 'critical' | 'major' | 'warning' | 'minor' | 'info';
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
metric?: string;
|
||||||
|
metricValue?: string;
|
||||||
|
threshold?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
date: string; // MM-DD
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStats {
|
||||||
|
running: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskDevice {
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
ip: string;
|
||||||
|
status: 'running' | 'success' | 'failed' | 'pending';
|
||||||
|
lastScanAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
interval?: number; // 巡检间隔(秒)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseMonitoringDataResult {
|
||||||
|
// 指标缓存
|
||||||
|
cpuMap: Record<string, number>;
|
||||||
|
memMap: Record<string, number>;
|
||||||
|
onlineMap: Record<string, boolean>;
|
||||||
|
timestampMap: Record<string, number>; // 每台设备最新巡检时间戳
|
||||||
|
|
||||||
|
// 告警
|
||||||
|
alerts: MonitoringAlert[];
|
||||||
|
alarmTrend: TrendPoint[];
|
||||||
|
alertStats: AlertStats | null;
|
||||||
|
|
||||||
|
// 巡检任务
|
||||||
|
taskStats: TaskStats;
|
||||||
|
taskDevices: TaskDevice[];
|
||||||
|
schedulerRunning: boolean;
|
||||||
|
|
||||||
|
// 操作
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== localStorage 持久化(降级用,API 不可用时显示缓存) =====
|
||||||
|
const LS_CPU = 'netmesh:monitoring:cpuMap';
|
||||||
|
const LS_MEM = 'netmesh:monitoring:memMap';
|
||||||
|
const LS_ALERTS = 'netmesh:monitoring:alerts';
|
||||||
|
const LS_TREND = 'netmesh:monitoring:trend';
|
||||||
|
|
||||||
|
function readLS<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) return fallback;
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLS(key: string, value: unknown) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 NetMesh AlertLevel 映射到监控页面的 5 级
|
||||||
|
function mapLevel(l: AlertLevel): MonitoringAlert['level'] {
|
||||||
|
if (l === 'critical') return 'critical';
|
||||||
|
if (l === 'warning') return 'warning';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化告警时间:今天 → HH:mm,昨天 → 昨天 HH:mm,更早 → MM-DD HH:mm
|
||||||
|
function formatAlertTime(ts: number): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = d.toDateString() === now.toDateString();
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const isYesterday = d.toDateString() === yesterday.toDateString();
|
||||||
|
const timeStr = d.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (isToday) return timeStr;
|
||||||
|
if (isYesterday) return `昨天 ${timeStr}`;
|
||||||
|
const ds = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
return `${ds} ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从巡检结果 metrics 里提取 cpu 使用率(数字百分比)
|
||||||
|
function extractCpu(metrics: Record<string, any>): number {
|
||||||
|
if (!metrics) return 0;
|
||||||
|
// NetMesh 巡检标准字段
|
||||||
|
if (typeof metrics.cpu_usage === 'number' && !isNaN(metrics.cpu_usage)) return metrics.cpu_usage;
|
||||||
|
if (typeof metrics.cpu_usage === 'string') {
|
||||||
|
const n = parseFloat(metrics.cpu_usage);
|
||||||
|
if (!isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
// 网络设备
|
||||||
|
if (metrics.cpuUtilization !== undefined) {
|
||||||
|
const v = Number(metrics.cpuUtilization);
|
||||||
|
if (!isNaN(v)) return v;
|
||||||
|
}
|
||||||
|
if (metrics.displayCpu?.cpuUsage !== undefined) {
|
||||||
|
const v = Number(metrics.displayCpu.cpuUsage);
|
||||||
|
if (!isNaN(v)) return v;
|
||||||
|
}
|
||||||
|
// 遍历所有字段找数值型 CPU 字段
|
||||||
|
for (const key of Object.keys(metrics)) {
|
||||||
|
const kl = key.toLowerCase();
|
||||||
|
if (kl.includes('cpu') && (kl.includes('usage') || kl.includes('util') || kl.includes('percent'))) {
|
||||||
|
const v = metrics[key];
|
||||||
|
if (typeof v === 'number' && !isNaN(v)) return v;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
if (!isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从巡检结果 metrics 里提取内存使用率
|
||||||
|
function extractMem(metrics: Record<string, any>): number {
|
||||||
|
if (!metrics) return 0;
|
||||||
|
// NetMesh 巡检标准字段
|
||||||
|
if (typeof metrics.memory_usage === 'number' && !isNaN(metrics.memory_usage)) return metrics.memory_usage;
|
||||||
|
if (typeof metrics.memory_usage === 'string') {
|
||||||
|
const n = parseFloat(metrics.memory_usage);
|
||||||
|
if (!isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
// 网络设备
|
||||||
|
if (metrics.displayMemory?.memPercent !== undefined) {
|
||||||
|
const v = Number(metrics.displayMemory.memPercent);
|
||||||
|
if (!isNaN(v)) return v;
|
||||||
|
}
|
||||||
|
if (metrics.memoryPercent !== undefined) {
|
||||||
|
const v = Number(metrics.memoryPercent);
|
||||||
|
if (!isNaN(v)) return v;
|
||||||
|
}
|
||||||
|
if (metrics.mem_percent !== undefined) {
|
||||||
|
const v = Number(metrics.mem_percent);
|
||||||
|
if (!isNaN(v)) return v;
|
||||||
|
}
|
||||||
|
// 遍历所有字段找数值型内存字段
|
||||||
|
for (const key of Object.keys(metrics)) {
|
||||||
|
const kl = key.toLowerCase();
|
||||||
|
if ((kl.includes('mem') || kl.includes('memory')) && (kl.includes('usage') || kl.includes('util') || kl.includes('percent'))) {
|
||||||
|
const v = metrics[key];
|
||||||
|
if (typeof v === 'number' && !isNaN(v)) return v;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = parseFloat(v);
|
||||||
|
if (!isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果有 mem_used 和 mem_total,计算百分比
|
||||||
|
const used = metrics.mem_used ?? metrics.memory_used ?? metrics.used_memory;
|
||||||
|
const total = metrics.mem_total ?? metrics.memory_total ?? metrics.total_memory;
|
||||||
|
if (typeof used === 'number' && typeof total === 'number' && total > 0) {
|
||||||
|
return Math.min(100, Math.round((used / total) * 100));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成过去 N 天的空趋势骨架
|
||||||
|
function buildTrendSkeleton(days = 14): TrendPoint[] {
|
||||||
|
const today = new Date();
|
||||||
|
const out: TrendPoint[] = [];
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const ds = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
out.push({ date: ds, count: 0 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把告警数组按天统计到趋势
|
||||||
|
function buildTrendFromAlerts(allAlerts: AlertRecord[], days = 14): TrendPoint[] {
|
||||||
|
const trend = buildTrendSkeleton(days);
|
||||||
|
const map = new Map(trend.map((t) => [t.date, 0]));
|
||||||
|
const today = new Date();
|
||||||
|
const earliest = new Date(today);
|
||||||
|
earliest.setDate(earliest.getDate() - days + 1);
|
||||||
|
earliest.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
for (const a of allAlerts) {
|
||||||
|
const t = new Date(a.triggered_at);
|
||||||
|
if (t < earliest) continue;
|
||||||
|
const ds = `${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
|
||||||
|
if (map.has(ds)) {
|
||||||
|
map.set(ds, (map.get(ds) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trend.map((t) => ({ ...t, count: map.get(t.date) || 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Hook =====
|
||||||
|
|
||||||
|
// 工具函数:从 Host + keys 构造 SSH 连接配置(用于调度器执行巡检)
|
||||||
|
function buildSshConfig(host: Host, keys: any[]): Record<string, unknown> {
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
host: host.hostname,
|
||||||
|
port: host.port || 22,
|
||||||
|
username: host.username,
|
||||||
|
label: host.label || host.hostname,
|
||||||
|
};
|
||||||
|
if (host.password) {
|
||||||
|
config.password = host.password;
|
||||||
|
}
|
||||||
|
// 私钥优先用 identityId 匹配 keys 数组
|
||||||
|
const keyId = host.identityId || host.identityFileId;
|
||||||
|
if (keyId) {
|
||||||
|
const key = keys.find((k) => k.id === keyId);
|
||||||
|
if (key?.privateKeyContent) {
|
||||||
|
config.privateKey = key.privateKeyContent;
|
||||||
|
if (key.passphrase) config.passphrase = key.passphrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitoringDataResult {
|
||||||
|
const [cpuMap, setCpuMap] = useState<Record<string, number>>(() => readLS(LS_CPU, {}));
|
||||||
|
const [memMap, setMemMap] = useState<Record<string, number>>(() => readLS(LS_MEM, {}));
|
||||||
|
const [onlineMap, setOnlineMap] = useState<Record<string, boolean>>({});
|
||||||
|
const [timestampMap, setTimestampMap] = useState<Record<string, number>>({});
|
||||||
|
const [alerts, setAlerts] = useState<MonitoringAlert[]>(() => {
|
||||||
|
const cached = readLS<MonitoringAlert[]>(LS_ALERTS, []);
|
||||||
|
return cached;
|
||||||
|
});
|
||||||
|
// 初始 alertStats 也从缓存告警计算,保证和列表初始状态一致
|
||||||
|
const [alertStats, setAlertStats] = useState<AlertStats | null>(() => {
|
||||||
|
const cached = readLS<MonitoringAlert[]>(LS_ALERTS, []);
|
||||||
|
if (cached.length === 0) return null;
|
||||||
|
let critical = 0, warning = 0, info = 0;
|
||||||
|
for (const a of cached) {
|
||||||
|
if (a.level === 'critical') critical++;
|
||||||
|
else if (a.level === 'warning') warning++;
|
||||||
|
else info++;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: cached.length,
|
||||||
|
active: cached.length,
|
||||||
|
active_critical: critical,
|
||||||
|
active_warning: warning,
|
||||||
|
active_info: info,
|
||||||
|
acknowledged: 0,
|
||||||
|
resolved: 0,
|
||||||
|
closed: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const [alarmTrend, setAlarmTrend] = useState<TrendPoint[]>(() => {
|
||||||
|
const cached = readLS<TrendPoint[]>(LS_TREND, []);
|
||||||
|
return cached.length > 0 ? cached : buildTrendSkeleton(14);
|
||||||
|
});
|
||||||
|
const [taskStats, setTaskStats] = useState<TaskStats>({
|
||||||
|
running: 0,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
pending: 0,
|
||||||
|
});
|
||||||
|
const [taskDevices, setTaskDevices] = useState<TaskDevice[]>([]);
|
||||||
|
const [schedulerRunning, setSchedulerRunning] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
// 用 ref 存最新的 hosts/keys,避免闭包陷阱
|
||||||
|
const hostsRef = useRef(hosts);
|
||||||
|
const keysRef = useRef(keys);
|
||||||
|
hostsRef.current = hosts;
|
||||||
|
keysRef.current = keys;
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
console.log('[monitoring] refresh() 被调用');
|
||||||
|
if (loadingRef.current) {
|
||||||
|
console.log('[monitoring] refresh() 正在执行中,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// ===== 1. 巡检最新结果 → CPU / 内存 / 在线状态 / 成功失败统计 =====
|
||||||
|
let latestResults: InspectionResult[] = [];
|
||||||
|
try {
|
||||||
|
const results: InspectionResult[] = await inspectionResultApi.getLatestAll();
|
||||||
|
latestResults = results;
|
||||||
|
const newCpu: Record<string, number> = {};
|
||||||
|
const newMem: Record<string, number> = {};
|
||||||
|
const newOnline: Record<string, boolean> = {};
|
||||||
|
const newTimestamps: Record<string, number> = {};
|
||||||
|
let successCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const cpu = extractCpu(r.metrics);
|
||||||
|
const mem = extractMem(r.metrics);
|
||||||
|
newCpu[r.device_id] = cpu;
|
||||||
|
newMem[r.device_id] = mem;
|
||||||
|
newOnline[r.device_id] = r.online !== false;
|
||||||
|
newTimestamps[r.device_id] = r.timestamp;
|
||||||
|
if (r.status === 'success') successCount++;
|
||||||
|
else failedCount++;
|
||||||
|
// 调试:打印每台设备的 metrics 结构,方便排查字段
|
||||||
|
console.log(`[monitoring] device ${r.device_id}: cpu=${cpu}, mem=${mem}, metrics keys=`, Object.keys(r.metrics || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[monitoring] loaded ${results.length} results: cpuMap=`, Object.keys(newCpu).length, 'memMap=', Object.keys(newMem).length);
|
||||||
|
|
||||||
|
setCpuMap((prev) => {
|
||||||
|
const merged = { ...prev, ...newCpu };
|
||||||
|
writeLS(LS_CPU, merged);
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
setMemMap((prev) => {
|
||||||
|
const merged = { ...prev, ...newMem };
|
||||||
|
writeLS(LS_MEM, merged);
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
setOnlineMap((prev) => ({ ...prev, ...newOnline }));
|
||||||
|
setTimestampMap((prev) => ({ ...prev, ...newTimestamps }));
|
||||||
|
setTaskStats((prev) => ({ ...prev, success: successCount, failed: failedCount }));
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[monitoring] getLatestAll failed, using cached:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 2. 活动告警(统一数据源:列表、概览、计数都从 active 列表里统计)=====
|
||||||
|
try {
|
||||||
|
const active: AlertRecord[] = await inspectionAlertApi.getActive();
|
||||||
|
console.log('[monitoring] getActive 返回:', active.length, '条告警');
|
||||||
|
|
||||||
|
const mapped: MonitoringAlert[] = active.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
deviceId: a.device_id,
|
||||||
|
deviceName: a.device_name,
|
||||||
|
level: mapLevel(a.level),
|
||||||
|
message: a.message || a.rule_name,
|
||||||
|
time: formatAlertTime(a.triggered_at),
|
||||||
|
metric: a.metric,
|
||||||
|
metricValue: a.metric_value,
|
||||||
|
threshold: a.threshold,
|
||||||
|
}));
|
||||||
|
setAlerts(mapped);
|
||||||
|
writeLS(LS_ALERTS, mapped);
|
||||||
|
|
||||||
|
// 统一从 active 列表统计告警计数(和列表、概览保持完全一致)
|
||||||
|
let activeCritical = 0;
|
||||||
|
let activeWarning = 0;
|
||||||
|
let activeInfo = 0;
|
||||||
|
for (const a of active) {
|
||||||
|
if (a.level === 'critical') activeCritical++;
|
||||||
|
else if (a.level === 'warning') activeWarning++;
|
||||||
|
else activeInfo++;
|
||||||
|
}
|
||||||
|
setAlertStats({
|
||||||
|
total: active.length, // 用活动告警总数,不是历史总告警
|
||||||
|
active: active.length,
|
||||||
|
active_critical: activeCritical,
|
||||||
|
active_warning: activeWarning,
|
||||||
|
active_info: activeInfo,
|
||||||
|
acknowledged: 0,
|
||||||
|
resolved: 0,
|
||||||
|
closed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 趋势:拉最近 200 条历史告警算 14 天趋势
|
||||||
|
try {
|
||||||
|
const all = await inspectionAlertApi.list({ limit: 200 });
|
||||||
|
const trend = buildTrendFromAlerts(all, 14);
|
||||||
|
setAlarmTrend(trend);
|
||||||
|
writeLS(LS_TREND, trend);
|
||||||
|
} catch { /* 忽略趋势拉取失败 */ }
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[monitoring] alerts failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 3. 调度器状态 + 设备扩展 → 任务统计 + 任务设备列表 =====
|
||||||
|
try {
|
||||||
|
const [schedulerStatus, enabledDevices, allExtensions] = await Promise.all([
|
||||||
|
inspectionSchedulerApi.status(),
|
||||||
|
inspectionDeviceApi.getEnabled(),
|
||||||
|
inspectionDeviceApi.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setSchedulerRunning(schedulerStatus.running);
|
||||||
|
const running = schedulerStatus.active_scans || 0;
|
||||||
|
|
||||||
|
// 构建结果 map
|
||||||
|
const resultMap = new Map(latestResults.map((r) => [r.device_id, r]));
|
||||||
|
|
||||||
|
// 构建所有设备的任务状态列表(过滤掉没有 hostname 的无效设备)
|
||||||
|
const allExtList = Object.values(allExtensions || {});
|
||||||
|
const taskDeviceList: TaskDevice[] = allExtList
|
||||||
|
.map((ext) => {
|
||||||
|
const host = hostsRef.current.find((h) => h.id === ext.host_id);
|
||||||
|
const result = resultMap.get(ext.host_id);
|
||||||
|
const isEnabled = ext.inspection_enabled;
|
||||||
|
|
||||||
|
// 没有 hostname 的设备(配置不完整),过滤掉
|
||||||
|
if (!host?.hostname) return null;
|
||||||
|
|
||||||
|
let status: TaskDevice['status'];
|
||||||
|
if (isEnabled) {
|
||||||
|
// 启用了定时巡检
|
||||||
|
if (result) {
|
||||||
|
if (result.status === 'success') status = 'success';
|
||||||
|
else status = 'failed';
|
||||||
|
} else {
|
||||||
|
// 启用了但还没巡检过 → 归为成功(表示已配置)
|
||||||
|
status = 'success';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 未启用定时巡检 → 待执行
|
||||||
|
status = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId: ext.host_id,
|
||||||
|
deviceName: host?.label || host?.hostname || ext.host_id,
|
||||||
|
ip: host?.hostname || '',
|
||||||
|
status,
|
||||||
|
lastScanAt: result?.timestamp,
|
||||||
|
durationMs: result?.duration_ms,
|
||||||
|
errorMessage: result?.error_message || undefined,
|
||||||
|
interval: ext.inspection_interval || undefined,
|
||||||
|
inspectionEnabled: isEnabled,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((d): d is TaskDevice => d !== null);
|
||||||
|
|
||||||
|
// 统计:执行中=启用巡检的设备数,成功/失败是其中的子集,待执行=未启用的
|
||||||
|
const enabledTaskDevices = taskDeviceList.filter((d) => d.inspectionEnabled);
|
||||||
|
const runningCount = enabledTaskDevices.length;
|
||||||
|
const successCount = enabledTaskDevices.filter((d) => d.status === 'success').length;
|
||||||
|
const failedCount = enabledTaskDevices.filter((d) => d.status === 'failed').length;
|
||||||
|
const pendingCount = taskDeviceList.filter((d) => !d.inspectionEnabled).length;
|
||||||
|
|
||||||
|
console.log('[monitoring] 任务统计:', { running: runningCount, success: successCount, failed: failedCount, pending: pendingCount, total: taskDeviceList.length });
|
||||||
|
console.log('[monitoring] 启用巡检设备数:', enabledTaskDevices.length, '全部扩展设备数:', allExtList.length, '巡检结果数:', latestResults.length);
|
||||||
|
|
||||||
|
setTaskDevices(taskDeviceList);
|
||||||
|
setTaskStats({
|
||||||
|
running: runningCount,
|
||||||
|
success: successCount,
|
||||||
|
failed: failedCount,
|
||||||
|
pending: pendingCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动启动调度器
|
||||||
|
if (enabledDevices.length === 0) {
|
||||||
|
console.log('[monitoring] 没有启用巡检的设备');
|
||||||
|
} else if (schedulerStatus.running) {
|
||||||
|
console.log('[monitoring] 调度器已在运行中');
|
||||||
|
} else {
|
||||||
|
console.log('[monitoring] 条件满足,准备启动调度器');
|
||||||
|
try {
|
||||||
|
const hostConfigs: Record<string, Record<string, unknown>> = {};
|
||||||
|
for (const dev of enabledDevices) {
|
||||||
|
const hostname = dev.hostname;
|
||||||
|
if (!hostname) {
|
||||||
|
console.log('[monitoring] 设备', dev.host_id, '没有 hostname,跳过');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const host = hostsRef.current.find((h) => h.hostname === hostname);
|
||||||
|
if (!host) {
|
||||||
|
console.log('[monitoring] vault 中未找到 hostname:', hostname, ',跳过');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!host.username) {
|
||||||
|
console.log('[monitoring] 设备', hostname, '缺少 username,跳过');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hostConfigs[dev.host_id] = buildSshConfig(host, keysRef.current);
|
||||||
|
}
|
||||||
|
if (Object.keys(hostConfigs).length === 0) {
|
||||||
|
console.log('[monitoring] 没有可巡检的设备(hostname 未写入或配置不完整)');
|
||||||
|
} else {
|
||||||
|
console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备');
|
||||||
|
await inspectionSchedulerApi.start(hostConfigs);
|
||||||
|
setSchedulerRunning(true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[monitoring] 自动启动调度器失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[monitoring] scheduler status failed:', e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始加载 + 定期刷新(每 30 秒)
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
const id = setInterval(refresh, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// hosts 变化时,把在线状态默认设为 true(没巡检数据前)
|
||||||
|
useEffect(() => {
|
||||||
|
setOnlineMap((prev) => {
|
||||||
|
const next: Record<string, boolean> = { ...prev };
|
||||||
|
let changed = false;
|
||||||
|
for (const h of hosts) {
|
||||||
|
if (next[h.id] === undefined) {
|
||||||
|
next[h.id] = true; // 假设在线,等巡检结果覆盖
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [hosts]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpuMap,
|
||||||
|
memMap,
|
||||||
|
onlineMap,
|
||||||
|
timestampMap,
|
||||||
|
alerts,
|
||||||
|
alarmTrend,
|
||||||
|
alertStats,
|
||||||
|
taskStats,
|
||||||
|
taskDevices,
|
||||||
|
schedulerRunning,
|
||||||
|
refresh,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Settings System Tab - System information, temp file management, session logs, and global hotkey
|
* Settings System Tab - System information, temp file management, session logs, and global hotkey
|
||||||
*/
|
*/
|
||||||
import { ChevronDown, ChevronRight, Download, ExternalLink, FolderOpen, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
import { ChevronDown, ChevronRight, FolderOpen, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||||
import type { AppLockSystemUnlockStatus } from "../../../application/state/useAppLockState";
|
import type { AppLockSystemUnlockStatus } from "../../../application/state/useAppLockState";
|
||||||
import type { AppLockSettings, AppLockSettingsChangeError, AppLockTimeoutMinutes } from "../../../domain/appLock";
|
import type { AppLockSettings, AppLockSettingsChangeError, AppLockTimeoutMinutes } from "../../../domain/appLock";
|
||||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||||
import type { UpdateState } from '../../../application/state/useUpdateCheck';
|
|
||||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||||
import type { HttpNetworkProxyMode, HttpNetworkProxySettings } from "../../../domain/httpNetworkProxy";
|
import type { HttpNetworkProxyMode, HttpNetworkProxySettings } from "../../../domain/httpNetworkProxy";
|
||||||
import { Button } from "../../ui/button";
|
import { Button } from "../../ui/button";
|
||||||
@@ -64,22 +63,6 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns a locale-agnostic relative time string for the given timestamp. */
|
|
||||||
function formatLastChecked(
|
|
||||||
timestamp: number | null,
|
|
||||||
t: (key: string) => string,
|
|
||||||
): string {
|
|
||||||
if (!timestamp) return '';
|
|
||||||
const diffMs = Date.now() - timestamp;
|
|
||||||
if (diffMs < 0) return t('settings.update.lastCheckedJustNow');
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
if (diffMins < 1) return t('settings.update.lastCheckedJustNow');
|
|
||||||
if (diffMins < 60)
|
|
||||||
return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins));
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
|
||||||
return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsSystemTabProps {
|
interface SettingsSystemTabProps {
|
||||||
appLockSettings: AppLockSettings;
|
appLockSettings: AppLockSettings;
|
||||||
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
|
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
|
||||||
@@ -129,14 +112,6 @@ interface SettingsSystemTabProps {
|
|||||||
hotkeyRegistrationError: string | null;
|
hotkeyRegistrationError: string | null;
|
||||||
globalHotkeyEnabled: boolean;
|
globalHotkeyEnabled: boolean;
|
||||||
setGlobalHotkeyEnabled: (enabled: boolean) => void;
|
setGlobalHotkeyEnabled: (enabled: boolean) => void;
|
||||||
autoUpdateEnabled: boolean;
|
|
||||||
setAutoUpdateEnabled: (enabled: boolean) => void;
|
|
||||||
// Unified update state — from useUpdateCheck hook in SettingsPageContent
|
|
||||||
updateState: UpdateState;
|
|
||||||
checkNow: () => Promise<unknown>;
|
|
||||||
installUpdate: () => void;
|
|
||||||
openReleasePage: () => void;
|
|
||||||
startDownload: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||||
@@ -181,13 +156,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
|||||||
hotkeyRegistrationError,
|
hotkeyRegistrationError,
|
||||||
globalHotkeyEnabled,
|
globalHotkeyEnabled,
|
||||||
setGlobalHotkeyEnabled,
|
setGlobalHotkeyEnabled,
|
||||||
autoUpdateEnabled,
|
|
||||||
setAutoUpdateEnabled,
|
|
||||||
updateState,
|
|
||||||
checkNow,
|
|
||||||
installUpdate,
|
|
||||||
openReleasePage,
|
|
||||||
startDownload,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||||
@@ -211,18 +179,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
|||||||
const [isClearingSessionLogs, setIsClearingSessionLogs] = useState(false);
|
const [isClearingSessionLogs, setIsClearingSessionLogs] = useState(false);
|
||||||
const [sessionLogsClearResult, setSessionLogsClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
const [sessionLogsClearResult, setSessionLogsClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||||
|
|
||||||
const [appVersion, setAppVersion] = useState('');
|
|
||||||
|
|
||||||
// Load app version on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const promise = netcattyBridge.get()?.getAppInfo?.();
|
|
||||||
if (promise) {
|
|
||||||
promise.then((info) => {
|
|
||||||
setAppVersion(info?.version ?? '');
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadTempDirInfo = useCallback(async () => {
|
const loadTempDirInfo = useCallback(async () => {
|
||||||
const bridge = netcattyBridge.get();
|
const bridge = netcattyBridge.get();
|
||||||
if (!bridge?.getTempDirInfo) return;
|
if (!bridge?.getTempDirInfo) return;
|
||||||
@@ -473,142 +429,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
|||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<SettingsTabContent value="system">
|
<SettingsTabContent value="system">
|
||||||
<SectionHeader title={t('settings.update.title')} anchorId="system-update" />
|
|
||||||
<SettingCard className="space-y-3 py-4">
|
|
||||||
{/* Current version */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t('settings.update.currentVersion')}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-mono">
|
|
||||||
{updateState.currentVersion || appVersion || '...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */}
|
|
||||||
{updateState.autoDownloadStatus === 'downloading' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}
|
|
||||||
</p>
|
|
||||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
|
||||||
style={{ width: `${updateState.downloadPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{updateState.autoDownloadStatus === 'ready' && (
|
|
||||||
<p className="text-sm text-green-600 dark:text-green-400">
|
|
||||||
{t('settings.update.readyToInstall')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{updateState.autoDownloadStatus === 'error' && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{updateState.downloadError || t('settings.update.error')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{updateState.autoDownloadStatus === 'idle' && (
|
|
||||||
<>
|
|
||||||
{updateState.manualCheckStatus === 'up-to-date' && (
|
|
||||||
<p className="text-sm text-green-600 dark:text-green-400">
|
|
||||||
{t('settings.update.upToDate')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
|
||||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
|
||||||
{t('settings.update.available').replace(
|
|
||||||
'{version}',
|
|
||||||
updateState.latestRelease?.version ?? ''
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{updateState.manualCheckStatus === 'error' && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{updateState.error || t('settings.update.error')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
|
||||||
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */}
|
|
||||||
{(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') &&
|
|
||||||
(updateState.isChecking || updateState.manualCheckStatus === 'checking') ? (
|
|
||||||
<Button variant="outline" size="sm" disabled>
|
|
||||||
<RefreshCw size={14} className="mr-1.5 animate-spin" />
|
|
||||||
{t('settings.update.checking')}
|
|
||||||
</Button>
|
|
||||||
) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? (
|
|
||||||
/* Check button — shown in idle states and in error state (allows retry) */
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void checkNow()}
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} className="mr-1.5" />
|
|
||||||
{t('settings.update.checkForUpdates')}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Install button — shown when download is complete */}
|
|
||||||
{updateState.autoDownloadStatus === 'ready' && (
|
|
||||||
<Button variant="default" size="sm" onClick={installUpdate}>
|
|
||||||
<RotateCcw size={14} className="mr-1.5" />
|
|
||||||
{t('settings.update.restartNow')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Open releases — shown on download error */}
|
|
||||||
{updateState.autoDownloadStatus === 'error' && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
|
||||||
<ExternalLink size={14} className="mr-1.5" />
|
|
||||||
{t('settings.update.manualDownload')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Download button — shown when update found and no download in progress */}
|
|
||||||
{updateState.autoDownloadStatus === 'idle' &&
|
|
||||||
updateState.manualCheckStatus === 'available' && (
|
|
||||||
<Button variant="outline" size="sm" onClick={startDownload}>
|
|
||||||
<Download size={14} className="mr-1.5" />
|
|
||||||
{t('update.downloadNow')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Open releases — fallback for unsupported platforms or check errors */}
|
|
||||||
{updateState.autoDownloadStatus === 'idle' &&
|
|
||||||
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
|
||||||
<ExternalLink size={14} className="mr-1.5" />
|
|
||||||
{t('settings.update.manualDownload')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<SettingRow
|
|
||||||
anchorId="system-auto-update"
|
|
||||||
label={t('settings.update.autoUpdateEnabled')}
|
|
||||||
description={t('settings.update.autoUpdateEnabledDesc')}
|
|
||||||
>
|
|
||||||
<Toggle
|
|
||||||
checked={autoUpdateEnabled}
|
|
||||||
onChange={setAutoUpdateEnabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingCard>
|
|
||||||
<SettingHint>
|
|
||||||
{updateState.lastCheckedAt && (
|
|
||||||
<span>
|
|
||||||
{t('settings.update.lastCheckedPrefix')}
|
|
||||||
{formatLastChecked(updateState.lastCheckedAt, t)}
|
|
||||||
{' '}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{t('settings.update.hint')}
|
|
||||||
</SettingHint>
|
|
||||||
|
|
||||||
<SectionHeader title={t("settings.autoLaunch.title")} />
|
<SectionHeader title={t("settings.autoLaunch.title")} />
|
||||||
<SettingCard className="py-4">
|
<SettingCard className="py-4">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
MemoryStick,
|
MemoryStick,
|
||||||
Network,
|
Network,
|
||||||
|
Server,
|
||||||
|
Plug,
|
||||||
|
Fan,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import React, { memo, useEffect, useMemo, useState } from 'react';
|
import React, { memo, useEffect, useMemo, useState } from 'react';
|
||||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||||
@@ -363,6 +366,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
|
|||||||
isConnected: true,
|
isConnected: true,
|
||||||
});
|
});
|
||||||
const hasStats = Boolean(stats.lastUpdated);
|
const hasStats = Boolean(stats.lastUpdated);
|
||||||
|
// Network device detection: backend sets this field only for H3C/Huawei/Cisco etc.
|
||||||
|
const isNetworkDevice = Boolean(stats.networkDevicePorts);
|
||||||
|
|
||||||
const memoryPercent = ratioPercent(stats?.memUsed, stats?.memTotal);
|
const memoryPercent = ratioPercent(stats?.memUsed, stats?.memTotal);
|
||||||
const mountedDiskUsage = aggregateMountedDiskUsage(stats.disks);
|
const mountedDiskUsage = aggregateMountedDiskUsage(stats.disks);
|
||||||
@@ -374,6 +379,22 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
|
|||||||
const loadOne = stats?.loadAverage?.[0] ?? null;
|
const loadOne = stats?.loadAverage?.[0] ?? null;
|
||||||
const loadPercent = ratioPercent(loadOne, stats?.cpuCores);
|
const loadPercent = ratioPercent(loadOne, stats?.cpuCores);
|
||||||
|
|
||||||
|
// Network device specific
|
||||||
|
const ndPorts = stats.networkDevicePorts ?? [];
|
||||||
|
const ndUp = stats.networkDevicePortUp ?? ndPorts.filter(p => p.phy === 'up').length;
|
||||||
|
const ndDown = stats.networkDevicePortDown ?? ndPorts.length - ndUp;
|
||||||
|
|
||||||
|
// Helper to shorten interface names: GigabitEthernet0/0/1 → GE0/0/1
|
||||||
|
const shortPortName = (name: string) => name
|
||||||
|
.replace(/^GigabitEthernet/i, 'GE')
|
||||||
|
.replace(/^TenGigEthernet/i, 'XGE')
|
||||||
|
.replace(/^TwentyFiveGigEthernet/i, '25GE')
|
||||||
|
.replace(/^FortyGigEthernet/i, '40GE')
|
||||||
|
.replace(/^HundredGigEthernet/i, '100GE')
|
||||||
|
.replace(/^Ethernet/i, 'Eth')
|
||||||
|
.replace(/^Serial/i, 'Ser')
|
||||||
|
.replace(/^Vlan-interface/i, 'Vlan');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
@@ -425,6 +446,89 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
|
|||||||
<SystemPanelLoading message={t('systemManager.overview.loading')} />
|
<SystemPanelLoading message={t('systemManager.overview.loading')} />
|
||||||
) : showEmpty ? (
|
) : showEmpty ? (
|
||||||
<SystemPanelEmpty icon={Activity} message={t('systemManager.overview.empty')} />
|
<SystemPanelEmpty icon={Activity} message={t('systemManager.overview.empty')} />
|
||||||
|
) : hasStats && isNetworkDevice ? (
|
||||||
|
/* ==================== NETWORK DEVICE VIEW ==================== */
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
|
||||||
|
{/* Banner */}
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
|
||||||
|
<Server size={13} className="text-violet-500" />
|
||||||
|
<span>网络设备模式 · VRP / Comware CLI</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main metric cards: CPU + Memory */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<MetricCard
|
||||||
|
label="CPU"
|
||||||
|
value={formatPercent(stats.cpu)}
|
||||||
|
detail="处理器使用率"
|
||||||
|
icon={Cpu}
|
||||||
|
gaugeValue={stats.cpu}
|
||||||
|
trendValues={trends.cpu}
|
||||||
|
trendMax={100}
|
||||||
|
tone="text-violet-500"
|
||||||
|
gradientId="nd-cpu-card"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="内存"
|
||||||
|
value={memoryPercent !== null ? formatPercent(memoryPercent) : '--'}
|
||||||
|
detail={`${formatMemoryMb(stats.memUsed)} / ${formatMemoryMb(stats.memTotal)}`}
|
||||||
|
icon={MemoryStick}
|
||||||
|
gaugeValue={memoryPercent}
|
||||||
|
trendValues={trends.memory}
|
||||||
|
trendMax={100}
|
||||||
|
tone="text-emerald-500"
|
||||||
|
gradientId="nd-mem-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Port status summary */}
|
||||||
|
<section
|
||||||
|
className="rounded-lg border p-3"
|
||||||
|
style={{ borderColor: 'hsl(var(--border) / 0.7)', background: 'hsl(var(--background))' }}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
|
||||||
|
<Network size={13} className="text-cyan-500" />
|
||||||
|
端口状态
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-[11px]">
|
||||||
|
<span className="text-emerald-500">↑ {ndUp} UP</span>
|
||||||
|
<span className="text-muted-foreground">↓ {ndDown} DOWN</span>
|
||||||
|
<span className="text-muted-foreground">共 {ndPorts.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ndPorts.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ndPorts.slice(0, 48).map((port) => (
|
||||||
|
<div
|
||||||
|
key={port.name}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums',
|
||||||
|
port.phy === 'up'
|
||||||
|
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600'
|
||||||
|
: 'border-muted/40 bg-muted/20 text-muted-foreground',
|
||||||
|
)}
|
||||||
|
title={`${port.name} — ${port.phy.toUpperCase()}`}
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', port.phy === 'up' ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
|
||||||
|
{shortPortName(port.name)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ndPorts.length > 48 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground py-0.5">+{ndPorts.length - 48} 更多</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>暂无端口数据</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Power / Fan status (placeholders — extend when we add display device parsing) */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InfoPill label="电源" value="未采集" icon={Plug} tone="text-amber-500" />
|
||||||
|
<InfoPill label="风扇" value="未采集" icon={Fan} tone="text-cyan-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : hasStats ? (
|
) : hasStats ? (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
|
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
|
||||||
{/* Main metric cards */}
|
{/* Main metric cards */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback, lazy, Suspense, useState, useEffect } from "react";
|
||||||
|
import { AlertTriangle, Bell, Activity, Network } from "lucide-react";
|
||||||
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
|
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
|
||||||
import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit";
|
import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit";
|
||||||
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
|
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
|
||||||
@@ -9,7 +10,8 @@ import {
|
|||||||
collectVisibleVaultHostIds,
|
collectVisibleVaultHostIds,
|
||||||
retainVisibleVaultGroupSelection,
|
retainVisibleVaultGroupSelection,
|
||||||
} from "../../domain/vaultGroupSelection";
|
} from "../../domain/vaultGroupSelection";
|
||||||
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
|
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH, STORAGE_KEY_PRODUCT_NAME } from "@/infrastructure/config/storageKeys.ts";
|
||||||
|
import { localStorageAdapter } from "@/infrastructure/persistence/localStorageAdapter.ts";
|
||||||
import { VaultHostListSection } from "./VaultHostListSection";
|
import { VaultHostListSection } from "./VaultHostListSection";
|
||||||
import { VaultImportProgressPanel } from "./ImportVaultDialog";
|
import { VaultImportProgressPanel } from "./ImportVaultDialog";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +25,14 @@ import { useNotesStore } from "../../application/state/notesStore";
|
|||||||
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
||||||
import { toast } from "../ui/toast";
|
import { toast } from "../ui/toast";
|
||||||
import { AppWordmark } from "../AppWordmark";
|
import { AppWordmark } from "../AppWordmark";
|
||||||
|
import { InspectionErrorBoundary } from "../inspection/InspectionErrorBoundary";
|
||||||
|
|
||||||
|
// 巡检模块页面(自包含,通过 IPC 直接调用主进程)
|
||||||
|
const AlertCenterPage = lazy(() => import("../inspection/AlertCenterPage"));
|
||||||
|
const AlertRulesPage = lazy(() => import("../inspection/AlertRulesPage"));
|
||||||
|
const InspectionCenterPage = lazy(() => import("../inspection/InspectionCenterPage"));
|
||||||
|
// 运行监控 - 3D 拓扑大屏
|
||||||
|
const MonitoringTopologyView = lazy(() => import("../monitoring/MonitoringTopologyView"));
|
||||||
|
|
||||||
type VaultViewLayoutContext = Record<string, any>;
|
type VaultViewLayoutContext = Record<string, any>;
|
||||||
|
|
||||||
@@ -123,6 +133,27 @@ function VaultConnectionLogsSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||||
|
// 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件
|
||||||
|
const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh');
|
||||||
|
|
||||||
|
// 监听 productName 变化
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
const newName = localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh';
|
||||||
|
console.log('[VaultViewLayout] productName changed to:', newName);
|
||||||
|
setProductName(newName);
|
||||||
|
};
|
||||||
|
// 监听 storage 事件(来自其他标签页)
|
||||||
|
window.addEventListener('storage', handler);
|
||||||
|
// 监听自定义事件(来自同一标签页的设置页面)
|
||||||
|
window.addEventListener('productNameChanged' as any, handler);
|
||||||
|
console.log('[VaultViewLayout] productName listener mounted, current:', productName);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handler);
|
||||||
|
window.removeEventListener('productNameChanged' as any, handler);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Activity,
|
Activity,
|
||||||
allGroupPaths,
|
allGroupPaths,
|
||||||
@@ -550,7 +581,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
|||||||
>
|
>
|
||||||
<AppLogo className="h-8 w-8 flex-shrink-0" />
|
<AppLogo className="h-8 w-8 flex-shrink-0" />
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<AppWordmark className="h-5 w-auto text-foreground" />
|
<span className="text-sm font-medium">{productName}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -772,6 +803,105 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div className="h-px bg-border/60 my-2" />
|
||||||
|
|
||||||
|
{/* 巡检中心 - inspection */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RippleButton
|
||||||
|
variant={currentSection === "inspection" ? "secondary" : "ghost"}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-10",
|
||||||
|
sidebarCollapsed
|
||||||
|
? "justify-center p-0"
|
||||||
|
: "justify-start gap-3",
|
||||||
|
currentSection === "inspection" &&
|
||||||
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentSection("inspection")}
|
||||||
|
>
|
||||||
|
<Activity size={16} className="flex-shrink-0" />
|
||||||
|
{!sidebarCollapsed && "巡检中心"}
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{sidebarCollapsed && (
|
||||||
|
<TooltipContent side="right">巡检中心</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* 告警中心 - alerts */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RippleButton
|
||||||
|
variant={currentSection === "alerts" ? "secondary" : "ghost"}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-10",
|
||||||
|
sidebarCollapsed
|
||||||
|
? "justify-center p-0"
|
||||||
|
: "justify-start gap-3",
|
||||||
|
currentSection === "alerts" &&
|
||||||
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentSection("alerts")}
|
||||||
|
>
|
||||||
|
<Bell size={16} className="flex-shrink-0" />
|
||||||
|
{!sidebarCollapsed && "告警中心"}
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{sidebarCollapsed && (
|
||||||
|
<TooltipContent side="right">告警中心</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* 告警规则 - alertRules */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RippleButton
|
||||||
|
variant={currentSection === "alertRules" ? "secondary" : "ghost"}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-10",
|
||||||
|
sidebarCollapsed
|
||||||
|
? "justify-center p-0"
|
||||||
|
: "justify-start gap-3",
|
||||||
|
currentSection === "alertRules" &&
|
||||||
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentSection("alertRules")}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={16} className="flex-shrink-0" />
|
||||||
|
{!sidebarCollapsed && "告警规则"}
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{sidebarCollapsed && (
|
||||||
|
<TooltipContent side="right">告警规则</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* 运行监控 - monitoring (3D 拓扑大屏) */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<RippleButton
|
||||||
|
variant={currentSection === "monitoring" ? "secondary" : "ghost"}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-10",
|
||||||
|
sidebarCollapsed
|
||||||
|
? "justify-center p-0"
|
||||||
|
: "justify-start gap-3",
|
||||||
|
currentSection === "monitoring" &&
|
||||||
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentSection("monitoring")}
|
||||||
|
>
|
||||||
|
<Network size={16} className="flex-shrink-0" />
|
||||||
|
{!sidebarCollapsed && "运行监控"}
|
||||||
|
</RippleButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{sidebarCollapsed && (
|
||||||
|
<TooltipContent side="right">运行监控</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -1402,6 +1532,82 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</LazyLoadBoundary>
|
</LazyLoadBoundary>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 巡检中心 */}
|
||||||
|
{currentSection === "inspection" && (
|
||||||
|
<LazyLoadBoundary name="Inspection center" resetKey="inspection-center">
|
||||||
|
<Suspense fallback={<VaultSectionLoading />}>
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<InspectionErrorBoundary>
|
||||||
|
<InspectionCenterPage hosts={ctx.hosts} keys={ctx.keys} />
|
||||||
|
</InspectionErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</LazyLoadBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 告警中心 */}
|
||||||
|
{currentSection === "alerts" && (
|
||||||
|
<LazyLoadBoundary name="Alert center" resetKey="alert-center">
|
||||||
|
<Suspense fallback={<VaultSectionLoading />}>
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<InspectionErrorBoundary>
|
||||||
|
<AlertCenterPage />
|
||||||
|
</InspectionErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</LazyLoadBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 告警规则配置 */}
|
||||||
|
{currentSection === "alertRules" && (
|
||||||
|
<LazyLoadBoundary name="Alert rules" resetKey="alert-rules">
|
||||||
|
<Suspense fallback={<VaultSectionLoading />}>
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<InspectionErrorBoundary>
|
||||||
|
<AlertRulesPage />
|
||||||
|
</InspectionErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</LazyLoadBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 运行监控 - 3D 拓扑大屏 */}
|
||||||
|
{currentSection === "monitoring" && (
|
||||||
|
<LazyLoadBoundary
|
||||||
|
name="Monitoring topology"
|
||||||
|
resetKey="monitoring-topology"
|
||||||
|
fallback={(error) => (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<div className="text-base font-medium text-foreground">Monitoring topology could not load.</div>
|
||||||
|
{error && (
|
||||||
|
<div className="w-full max-w-xl overflow-auto rounded border border-red-500/30 bg-red-500/5 p-3 text-left text-xs text-red-400">
|
||||||
|
<div className="mb-1 font-semibold">Error: {error.message}</div>
|
||||||
|
<pre className="whitespace-pre-wrap font-mono text-[10px] opacity-80">
|
||||||
|
{error.stack}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Suspense fallback={<VaultSectionLoading />}>
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<MonitoringTopologyView
|
||||||
|
hosts={ctx.hosts}
|
||||||
|
keys={ctx.keys}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</LazyLoadBoundary>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group Details Panel */}
|
{/* Group Details Panel */}
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ export function shouldCollectServerStats(
|
|||||||
_session: TerminalSession | null | undefined,
|
_session: TerminalSession | null | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
const detectedDeviceClass = classifyDistroId(host?.distro);
|
const detectedDeviceClass = classifyDistroId(host?.distro);
|
||||||
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
|
// Network devices (H3C, Huawei, Cisco, etc.) now have their own PTY-shell
|
||||||
|
// based stats collector via getServerStats — allow polling so the panel
|
||||||
|
// shows CPU/memory/port data instead of staying empty.
|
||||||
|
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return true;
|
||||||
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
|
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
|
||||||
return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin' || capabilities.targetOs === 'windows';
|
return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin' || capabilities.targetOs === 'windows';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const { etExtraResources } = require('./scripts/et-extra-resources.cjs');
|
|||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
appId: 'com.netcatty.app',
|
appId: 'com.netcatty.app',
|
||||||
productName: 'Netcatty',
|
productName: 'NetMesh',
|
||||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||||
protocols: [
|
protocols: [
|
||||||
{
|
{
|
||||||
@@ -242,7 +242,7 @@ module.exports = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
win: {
|
win: {
|
||||||
icon: 'public/icon-win.png',
|
icon: 'build/icon.ico',
|
||||||
// Do not hard-code arch here. pack:win-x64 / pack:win pass --x64/--arm64,
|
// Do not hard-code arch here. pack:win-x64 / pack:win pass --x64/--arm64,
|
||||||
// and electron-builder unions config arch with the CLI set. Hard-coding
|
// and electron-builder unions config arch with the CLI set. Hard-coding
|
||||||
// ['x64', 'arm64'] made the official x64 CI job also emit win-arm64 and
|
// ['x64', 'arm64'] made the official x64 CI job also emit win-arm64 and
|
||||||
@@ -272,7 +272,7 @@ module.exports = {
|
|||||||
allowToChangeInstallationDirectory: true,
|
allowToChangeInstallationDirectory: true,
|
||||||
createDesktopShortcut: true,
|
createDesktopShortcut: true,
|
||||||
createStartMenuShortcut: true,
|
createStartMenuShortcut: true,
|
||||||
shortcutName: 'Netcatty'
|
shortcutName: 'NetMesh'
|
||||||
},
|
},
|
||||||
linux: {
|
linux: {
|
||||||
// Linux .deb/.rpm/AppImage icons come from build/icons/* (see
|
// Linux .deb/.rpm/AppImage icons come from build/icons/* (see
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const {
|
|||||||
verifyAppLockPassword,
|
verifyAppLockPassword,
|
||||||
} = require("./appLockSettingsStore.cjs");
|
} = require("./appLockSettingsStore.cjs");
|
||||||
|
|
||||||
|
// 默认产品名称(可通过环境变量 NETCATTY_PRODUCT_NAME 覆盖)
|
||||||
|
const DEFAULT_PRODUCT_NAME = process.env.NETCATTY_PRODUCT_NAME || "NetMesh";
|
||||||
|
|
||||||
function normalizeReason(reason) {
|
function normalizeReason(reason) {
|
||||||
return typeof reason === "string" && reason.trim() !== "" ? reason : null;
|
return typeof reason === "string" && reason.trim() !== "" ? reason : null;
|
||||||
}
|
}
|
||||||
@@ -319,11 +322,12 @@ function createAppLockController({
|
|||||||
try {
|
try {
|
||||||
if (typeof win.getTitle === "function") {
|
if (typeof win.getTitle === "function") {
|
||||||
const currentTitle = win.getTitle();
|
const currentTitle = win.getTitle();
|
||||||
if (currentTitle && currentTitle !== "NetMesh") {
|
if (currentTitle && currentTitle !== DEFAULT_PRODUCT_NAME) {
|
||||||
lockedWindowTitles.set(win, currentTitle);
|
// 保留用户设置的自定义标题
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
win.setTitle?.("NetMesh");
|
win.setTitle?.(DEFAULT_PRODUCT_NAME);
|
||||||
if (win.webContents?.isDevToolsOpened?.()) {
|
if (win.webContents?.isDevToolsOpened?.()) {
|
||||||
win.webContents.closeDevTools?.();
|
win.webContents.closeDevTools?.();
|
||||||
}
|
}
|
||||||
@@ -366,11 +370,11 @@ function createAppLockController({
|
|||||||
|
|
||||||
function setWindowTitle(win, title) {
|
function setWindowTitle(win, title) {
|
||||||
if (!win || win.isDestroyed?.()) return false;
|
if (!win || win.isDestroyed?.()) return false;
|
||||||
const nextTitle = typeof title === "string" && title.trim() ? title.trim() : "NetMesh";
|
const nextTitle = typeof title === "string" && title.trim() ? title.trim() : DEFAULT_PRODUCT_NAME;
|
||||||
try {
|
try {
|
||||||
if (getRuntimeState()?.locked === true) {
|
if (getRuntimeState()?.locked === true) {
|
||||||
lockedWindowTitles.set(win, nextTitle);
|
lockedWindowTitles.set(win, nextTitle);
|
||||||
win.setTitle?.("NetMesh");
|
win.setTitle?.(DEFAULT_PRODUCT_NAME);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
win.setTitle?.(nextTitle);
|
win.setTitle?.(nextTitle);
|
||||||
@@ -388,8 +392,8 @@ function createAppLockController({
|
|||||||
|
|
||||||
for (const [win, title] of lockedWindowTitles) {
|
for (const [win, title] of lockedWindowTitles) {
|
||||||
try {
|
try {
|
||||||
if (!win.isDestroyed?.() && win.getTitle?.() === "Netcatty") {
|
if (!win.isDestroyed?.() && win.getTitle?.() === DEFAULT_PRODUCT_NAME) {
|
||||||
win.setTitle?.(title || "Netcatty");
|
win.setTitle?.(title || DEFAULT_PRODUCT_NAME);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore disposed windows during unlock
|
// ignore disposed windows during unlock
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ function openBoundedSshExecStream(
|
|||||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||||
return Promise.reject(new Error("SSH exec unavailable"));
|
return Promise.reject(new Error("SSH exec unavailable"));
|
||||||
}
|
}
|
||||||
|
// [NETWORK-DEVICE GATE] H3C / Huawei VRP / Comware / Cisco etc. only allow
|
||||||
|
// one SSH channel per transport. Opening an exec channel on the same conn
|
||||||
|
// drops the user's interactive shell outright. Detect from the SSH banner
|
||||||
|
// stored on the ssh2 Client and bail out early.
|
||||||
|
const _v = typeof sshClient._remoteVer === "string" ? sshClient._remoteVer : "";
|
||||||
|
if (
|
||||||
|
/^H3C[-_\s]/i.test(_v)
|
||||||
|
|| /^Comware-/i.test(_v)
|
||||||
|
|| /^HUAWEI[-_]/i.test(_v)
|
||||||
|
|| /^VRP-/i.test(_v)
|
||||||
|
|| _v === "-"
|
||||||
|
|| /^Cisco[-_]|^CiscoIOS_/i.test(_v)
|
||||||
|
|| /^RGOS_SSH/i.test(_v)
|
||||||
|
|| /^JUNOS/i.test(_v)
|
||||||
|
) {
|
||||||
|
return Promise.reject(Object.assign(
|
||||||
|
new Error("SSH exec channel not supported on network device (vendor CLI only allows one channel per connection)"),
|
||||||
|
{ code: "SSH_EXEC_NOT_SUPPORTED" },
|
||||||
|
));
|
||||||
|
}
|
||||||
const signal = options.signal || null;
|
const signal = options.signal || null;
|
||||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||||
|
|||||||
95
electron/bridges/inspection/adapterRegistry.cjs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集器注册表
|
||||||
|
*
|
||||||
|
* 自动注册 adapters 目录下的所有采集器,供调度器使用。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* const adapterRegistry = require("./adapterRegistry.cjs");
|
||||||
|
* const adapter = adapterRegistry.createAdapter("LinuxServerAdapter", connInfo, options);
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const BaseAdapter = require("./adapters/baseAdapter.cjs");
|
||||||
|
|
||||||
|
const ADAPTERS_DIR = path.join(__dirname, "adapters");
|
||||||
|
const registry = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描 adapters 目录,自动注册所有采集器类
|
||||||
|
* (只注册已实现的文件,不存在的适配器不报错,留作后续扩展)
|
||||||
|
*/
|
||||||
|
function loadAdapters() {
|
||||||
|
if (!fs.existsSync(ADAPTERS_DIR)) return;
|
||||||
|
|
||||||
|
const files = fs.readdirSync(ADAPTERS_DIR);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith(".cjs")) continue;
|
||||||
|
if (file === "baseAdapter.cjs") continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modulePath = path.join(ADAPTERS_DIR, file);
|
||||||
|
const AdapterClass = require(modulePath);
|
||||||
|
|
||||||
|
// 验证是否继承自 BaseAdapter
|
||||||
|
if (
|
||||||
|
typeof AdapterClass === "function" &&
|
||||||
|
AdapterClass.prototype instanceof BaseAdapter
|
||||||
|
) {
|
||||||
|
console.log(`[Inspection] 注册采集器: ${AdapterClass.name} (${file})`);
|
||||||
|
registry[AdapterClass.name] = AdapterClass;
|
||||||
|
} else {
|
||||||
|
console.warn(`[Inspection] ${file} 不是 BaseAdapter 子类,导出类型: ${typeof AdapterClass}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 加载失败跳过(比如引用了不存在的模块)
|
||||||
|
console.error(`[Inspection] 加载采集器失败: ${file}`, e.message, e.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时自动加载
|
||||||
|
loadAdapters();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已注册的采集器列表
|
||||||
|
* @returns {string[]} 采集器类名列表
|
||||||
|
*/
|
||||||
|
function getRegisteredAdapters() {
|
||||||
|
return Object.keys(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查采集器是否已注册
|
||||||
|
* @param {string} className
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hasAdapter(className) {
|
||||||
|
return !!registry[className];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建采集器实例
|
||||||
|
* @param {string} className - 采集器类名(如 "LinuxServerAdapter")
|
||||||
|
* @param {object} connectionInfo - SSH 连接信息
|
||||||
|
* @param {object} [options] - 配置项
|
||||||
|
* @returns {BaseAdapter|null}
|
||||||
|
*/
|
||||||
|
function createAdapter(className, connectionInfo, options = {}) {
|
||||||
|
const AdapterClass = registry[className];
|
||||||
|
if (!AdapterClass) {
|
||||||
|
console.warn(`[Inspection] 未找到采集器: ${className}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new AdapterClass(connectionInfo, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadAdapters,
|
||||||
|
getRegisteredAdapters,
|
||||||
|
hasAdapter,
|
||||||
|
createAdapter,
|
||||||
|
};
|
||||||
127
electron/bridges/inspection/adapters/baseAdapter.cjs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集器基类
|
||||||
|
*
|
||||||
|
* 所有设备采集器都继承此类,统一采集接口。
|
||||||
|
* 采集器负责:
|
||||||
|
* 1. 建立 SSH 连接
|
||||||
|
* 2. 执行巡检命令
|
||||||
|
* 3. 解析输出为结构化指标数据
|
||||||
|
* 4. 关闭连接
|
||||||
|
*
|
||||||
|
* 子类需实现:
|
||||||
|
* - getSupportedMetrics() 返回支持的指标列表
|
||||||
|
* - collectMetrics() 执行所有采集命令并返回结构化指标
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { INSPECTION_STATUS } = require("../types.cjs");
|
||||||
|
|
||||||
|
class BaseAdapter {
|
||||||
|
/**
|
||||||
|
* @param {object} connectionInfo - SSH 连接信息
|
||||||
|
* @param {string} connectionInfo.host - 主机 IP
|
||||||
|
* @param {number} connectionInfo.port - 端口
|
||||||
|
* @param {string} connectionInfo.username - 用户名
|
||||||
|
* @param {string} [connectionInfo.password] - 密码
|
||||||
|
* @param {string} [connectionInfo.privateKey] - 私钥
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {number} [options.timeoutMs] - 单条命令超时(毫秒)
|
||||||
|
*/
|
||||||
|
constructor(connectionInfo, options = {}) {
|
||||||
|
this.connectionInfo = connectionInfo;
|
||||||
|
this.timeoutMs = options.timeoutMs || 15000;
|
||||||
|
this.conn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立连接
|
||||||
|
* 子类可以覆盖此方法实现不同的连接方式(如 Telnet)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
// 基类不实现具体连接逻辑,由上层调度器负责 SSH 连接
|
||||||
|
// 或者由子类自行实现
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单条命令
|
||||||
|
* 由子类或注入的 exec 函数实现
|
||||||
|
* @param {string} command
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async execute(command) {
|
||||||
|
throw new Error("execute() must be implemented by subclass or injected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭连接
|
||||||
|
*/
|
||||||
|
async disconnect() {
|
||||||
|
// 子类可覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支持的指标列表
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
getSupportedMetrics() {
|
||||||
|
throw new Error("getSupportedMetrics() must be implemented by subclass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行完整巡检,返回结构化指标数据
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
* {
|
||||||
|
* online: boolean,
|
||||||
|
* metrics: { ... },
|
||||||
|
* raw: { ... } // 原始输出(调试用)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
async collect() {
|
||||||
|
const result = {
|
||||||
|
online: false,
|
||||||
|
metrics: {},
|
||||||
|
raw: {},
|
||||||
|
error: null,
|
||||||
|
status: INSPECTION_STATUS.SUCCESS,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metrics = await this.collectMetrics();
|
||||||
|
result.online = true;
|
||||||
|
result.metrics = metrics;
|
||||||
|
} catch (err) {
|
||||||
|
result.error = err.message;
|
||||||
|
result.status = err.message.includes("timeout")
|
||||||
|
? INSPECTION_STATUS.TIMEOUT
|
||||||
|
: INSPECTION_STATUS.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子类实现:具体的采集逻辑
|
||||||
|
* @returns {Promise<object>} 结构化指标数据
|
||||||
|
*/
|
||||||
|
async collectMetrics() {
|
||||||
|
throw new Error("collectMetrics() must be implemented by subclass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地从字符串中提取数字
|
||||||
|
* @param {string} text
|
||||||
|
* @param {RegExp} regex
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
extractNumber(text, regex) {
|
||||||
|
const match = (text || "").match(regex);
|
||||||
|
if (!match) return null;
|
||||||
|
const num = parseFloat(match[1]);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseAdapter;
|
||||||
174
electron/bridges/inspection/adapters/huaweiH3CSwitchAdapter.cjs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 华为 / H3C 交换机采集器
|
||||||
|
*
|
||||||
|
* 采集指标:
|
||||||
|
* - cpu_usage CPU 使用率(%)
|
||||||
|
* - memory_usage 内存使用率(%)
|
||||||
|
* - port_status 端口状态 { up_count, down_count }
|
||||||
|
* - power_status 电源状态 { normal_count, abnormal_count }
|
||||||
|
* - fan_status 风扇状态
|
||||||
|
*
|
||||||
|
* 实现说明:
|
||||||
|
* 使用批量命令执行,一次连接执行所有命令,避免 VTY 超时
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BaseAdapter = require("./baseAdapter.cjs");
|
||||||
|
|
||||||
|
class HuaweiH3CSwitchAdapter extends BaseAdapter {
|
||||||
|
constructor(connectionInfo, options = {}) {
|
||||||
|
super(connectionInfo, options);
|
||||||
|
this.adapterName = "huawei_h3c_switch";
|
||||||
|
if (options.execCommands) {
|
||||||
|
this.execCommands = options.execCommands;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execCommands(commands, options = {}) {
|
||||||
|
const { execSshCommands } = require("../inspectionExec.cjs");
|
||||||
|
return await execSshCommands(this.connectionInfo, commands, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedMetrics() {
|
||||||
|
return [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"port_status",
|
||||||
|
"power_status",
|
||||||
|
"fan_status",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectMetrics() {
|
||||||
|
const metrics = {};
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
|
|
||||||
|
if (DEBUG) console.log("[HuaweiH3C] 开始采集交换机指标");
|
||||||
|
|
||||||
|
const commands = [
|
||||||
|
"display cpu-usage",
|
||||||
|
"display memory",
|
||||||
|
"display interface brief",
|
||||||
|
"display power",
|
||||||
|
"display fan",
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results } = await this.execCommands(commands, { timeoutMs: 180000 });
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[HuaweiH3C] 原始输出:");
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
// 简化日志,不输出完整原始数据
|
||||||
|
const out = results[i]?.output || "";
|
||||||
|
console.log(`[HuaweiH3C] 命令${i + 1}: ${commands[i]} → ${out.length} 字符`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results && results.length >= 5) {
|
||||||
|
metrics.cpu_usage = this.parseCpuUsage(results[0]?.output || "");
|
||||||
|
metrics.memory_usage = this.parseMemoryUsage(results[1]?.output || "");
|
||||||
|
metrics.port_status = this.parsePorts(results[2]?.output || "");
|
||||||
|
metrics.power_status = this.parsePower(results[3]?.output || "");
|
||||||
|
metrics.fan_status = this.parseFan(results[4]?.output || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG) console.log("[HuaweiH3C] Final:", JSON.stringify(metrics));
|
||||||
|
} catch (e) {
|
||||||
|
if (DEBUG) console.log("[HuaweiH3C] Error:", e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCpuUsage(output) {
|
||||||
|
// H3C Comware 5/7: "7% in last 5 seconds"
|
||||||
|
let m = output.match(/(\d+)\s*%\s*in\s+last\s+5\s+seconds/i);
|
||||||
|
if (m) return parseFloat(m[1]);
|
||||||
|
// 1 minute
|
||||||
|
m = output.match(/(\d+)\s*%\s*in\s+last\s+1\s+minute/i);
|
||||||
|
if (m) return parseFloat(m[1]);
|
||||||
|
// 华为格式
|
||||||
|
m = output.match(/CPU\s+Usage\s*:\s*(\d+)%/i);
|
||||||
|
if (m) return parseFloat(m[1]);
|
||||||
|
// 5 Seconds
|
||||||
|
m = output.match(/5\s*Seconds\s*[::]\s*(\d+)\s*%/i);
|
||||||
|
if (m) return parseFloat(m[1]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseMemoryUsage(output) {
|
||||||
|
// H3C: "Used Rate: 45%"
|
||||||
|
let m = output.match(/Used\s*Rate\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/i);
|
||||||
|
if (m) return parseFloat(m[1]);
|
||||||
|
// 华为/其他: "Memory Using Percentage Is: 21%"
|
||||||
|
m = output.match(/Memory\s+Using\s+Percentage\s+Is\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/i);
|
||||||
|
if (m) return parseFloat(m[1]);
|
||||||
|
// H3C bytes 格式: "System Total Memory(bytes): 65546160" / "Total Used Memory(bytes): 29528544"
|
||||||
|
const totalM = output.match(/System\s+Total\s+Memory\s*\(\s*bytes\s*\)\s*[::]?\s*(\d+)/i);
|
||||||
|
const usedM = output.match(/Total\s+Used\s+Memory\s*\(\s*bytes\s*\)\s*[::]?\s*(\d+)/i);
|
||||||
|
if (totalM && usedM) {
|
||||||
|
const total = parseFloat(totalM[1]);
|
||||||
|
const used = parseFloat(usedM[1]);
|
||||||
|
if (total > 0) return Math.round((used / total) * 100 * 10) / 10;
|
||||||
|
}
|
||||||
|
// MB 格式: "Memory Size: xxx MB"
|
||||||
|
const totalMB = output.match(/Memory\s*(?:Size|Total)\s*[::]?\s*(\d+)\s*MB/i);
|
||||||
|
const usedMB = output.match(/Memory\s*Used\s*[::]?\s*(\d+)\s*MB/i);
|
||||||
|
if (totalMB && usedMB) {
|
||||||
|
const total = parseFloat(totalMB[1]);
|
||||||
|
const used = parseFloat(usedMB[1]);
|
||||||
|
if (total > 0) return Math.round((used / total) * 100 * 10) / 10;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsePorts(output) {
|
||||||
|
const lines = output.split("\n");
|
||||||
|
let upCount = 0, downCount = 0;
|
||||||
|
const ports = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
// H3C 格式: "GE1/0/1 UP 100M(a)"
|
||||||
|
// 匹配 GE开头 + UP/DOWN
|
||||||
|
const geMatch = line.match(/^(GE\d+\/\d+\/\d+)\s+(UP|DOWN|up|down)/i);
|
||||||
|
if (geMatch) {
|
||||||
|
const phy = geMatch[2].toLowerCase();
|
||||||
|
ports.push({ name: geMatch[1], phy });
|
||||||
|
if (phy === "up") upCount++;
|
||||||
|
else downCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 匹配 GigabitEthernet 全称
|
||||||
|
const gigMatch = line.match(/^(GigabitEthernet\d+[\d\/]+)\s+(UP|DOWN|up|down)/i);
|
||||||
|
if (gigMatch) {
|
||||||
|
const phy = gigMatch[2].toLowerCase();
|
||||||
|
ports.push({ name: gigMatch[1], phy });
|
||||||
|
if (phy === "up") upCount++;
|
||||||
|
else downCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { up_count: upCount, down_count: downCount, ports };
|
||||||
|
}
|
||||||
|
|
||||||
|
parsePower(output) {
|
||||||
|
let normal = 0, abnormal = 0;
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
if (/Normal|Supply|OK/i.test(line)) normal++;
|
||||||
|
else if (/Absent|Fault|Error/i.test(line)) abnormal++;
|
||||||
|
}
|
||||||
|
return { normal_count: normal, abnormal_count: abnormal };
|
||||||
|
}
|
||||||
|
|
||||||
|
parseFan(output) {
|
||||||
|
let normal = 0, abnormal = 0;
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
if (/Normal|Auto|Running|OK/i.test(line)) normal++;
|
||||||
|
else if (/Fault|Absent|Error/i.test(line)) abnormal++;
|
||||||
|
}
|
||||||
|
return { normal_count: normal, abnormal_count: abnormal };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = HuaweiH3CSwitchAdapter;
|
||||||
102
electron/bridges/inspection/adapters/linuxServerAdapter.cjs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux 服务器采集器
|
||||||
|
*
|
||||||
|
* 采集指标(告警相关):
|
||||||
|
* - cpu_usage CPU 使用率(%)
|
||||||
|
* - memory_usage 内存使用率(%)
|
||||||
|
* - disk_usage 磁盘使用率(%,只显示超过 80% 的挂载点)
|
||||||
|
*
|
||||||
|
* 实现说明:
|
||||||
|
* 直接使用 POSIX shell 命令,命令与 NetMesh System Manager 的概览一致。
|
||||||
|
* 传入 exec 函数来执行远程命令(由调度器注入,复用 SSH 连接池)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BaseAdapter = require("./baseAdapter.cjs");
|
||||||
|
|
||||||
|
class LinuxServerAdapter extends BaseAdapter {
|
||||||
|
constructor(connectionInfo, options = {}) {
|
||||||
|
super(connectionInfo, options);
|
||||||
|
this.adapterName = "linux_server";
|
||||||
|
// 支持外部注入 exec 函数(用于复用 SSH 连接)
|
||||||
|
if (options.execFn) {
|
||||||
|
this.execute = options.execFn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedMetrics() {
|
||||||
|
return [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"disk_usage",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectMetrics() {
|
||||||
|
const metrics = {};
|
||||||
|
const raw = {};
|
||||||
|
|
||||||
|
// 1. CPU 使用率(采样 top)
|
||||||
|
try {
|
||||||
|
raw.cpu = await this.execute("top -bn1 2>/dev/null | head -n 5");
|
||||||
|
const cpuMatch = (raw.cpu || "").match(/Cpu\(s\):\s*([\d.]+)\s*us,\s*([\d.]+)\s*sy/i);
|
||||||
|
if (cpuMatch) {
|
||||||
|
metrics.cpu_usage = Math.round(parseFloat(cpuMatch[1]) + parseFloat(cpuMatch[2]));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw e; // SSH 失败或其他错误必须向上传播
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 内存使用率
|
||||||
|
try {
|
||||||
|
raw.mem = await this.execute("free -k 2>/dev/null | head -n 2 | tail -n 1");
|
||||||
|
// 支持中英文输出: "Mem:" 或 "内存:"
|
||||||
|
const memMatch = (raw.mem || "").match(/(?:Mem:|内存[::])\s+(\d+)\s+(\d+)/);
|
||||||
|
if (memMatch) {
|
||||||
|
const total = parseInt(memMatch[1], 10);
|
||||||
|
const used = parseInt(memMatch[2], 10);
|
||||||
|
if (total > 0) {
|
||||||
|
metrics.memory_usage = Math.min(100, Math.round((used / total) * 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 磁盘使用率(显示所有挂载点)
|
||||||
|
try {
|
||||||
|
raw.disk = await this.execute("df -h 2>/dev/null | tail -n +2 | grep -v '/dev/loop'");
|
||||||
|
const disks = this.parseDiskUsage(raw.disk);
|
||||||
|
if (disks.length > 0) metrics.disk_usage = disks;
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析磁盘使用率(df -h 输出格式),返回所有挂载点
|
||||||
|
*/
|
||||||
|
parseDiskUsage(text) {
|
||||||
|
const disks = [];
|
||||||
|
const lines = (text || "").split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts.length < 6) continue;
|
||||||
|
const usageStr = parts[4] || "";
|
||||||
|
const usageMatch = usageStr.match(/^(\d+)%/);
|
||||||
|
if (!usageMatch) continue;
|
||||||
|
const usagePercent = parseInt(usageMatch[1], 10);
|
||||||
|
disks.push({
|
||||||
|
filesystem: parts[0],
|
||||||
|
usage_percent: usagePercent,
|
||||||
|
mount: parts[parts.length - 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return disks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LinuxServerAdapter;
|
||||||
237
electron/bridges/inspection/adapters/windowsServerAdapter.cjs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows 服务器采集器
|
||||||
|
*
|
||||||
|
* 采集指标:
|
||||||
|
* - cpu_usage CPU 使用率(%)
|
||||||
|
* - memory_usage 内存使用率(%)
|
||||||
|
* - disk_usage 磁盘使用率(%)
|
||||||
|
* - process_count 进程数
|
||||||
|
* - uptime 运行时间
|
||||||
|
* - service_status 关键服务状态
|
||||||
|
*
|
||||||
|
* 实现说明:
|
||||||
|
* 使用 PowerShell Base64 EncodedCommand 方式执行,与 NetMesh System Manager 一致。
|
||||||
|
* 传入 exec 函数执行远程命令(由调度器注入)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BaseAdapter = require("./baseAdapter.cjs");
|
||||||
|
|
||||||
|
class WindowsServerAdapter extends BaseAdapter {
|
||||||
|
constructor(connectionInfo, options = {}) {
|
||||||
|
super(connectionInfo, options);
|
||||||
|
this.adapterName = "windows_server";
|
||||||
|
if (options.execFn) {
|
||||||
|
this.execute = options.execFn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupportedMetrics() {
|
||||||
|
return [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"disk_usage",
|
||||||
|
"process_count",
|
||||||
|
"uptime",
|
||||||
|
"service_status",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectMetrics() {
|
||||||
|
const metrics = {};
|
||||||
|
const raw = {};
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
|
|
||||||
|
// 1. CPU + 内存 + 运行时间(快速,独立执行)
|
||||||
|
try {
|
||||||
|
const script = this._buildFastStatsScript();
|
||||||
|
const cmd = this._buildPowerShellCmd(script);
|
||||||
|
if (DEBUG) console.log("[WinAdapter] 执行命令:", cmd);
|
||||||
|
raw.stats = await this.execute(cmd);
|
||||||
|
if (DEBUG) console.log("[WinAdapter] 原始输出:", raw.stats);
|
||||||
|
const parsed = this._parseStats(raw.stats);
|
||||||
|
if (DEBUG) console.log("[WinAdapter] 解析结果:", parsed);
|
||||||
|
Object.assign(metrics, parsed);
|
||||||
|
} catch (e) {
|
||||||
|
raw.stats_error = e.message;
|
||||||
|
if (DEBUG) console.error("[WinAdapter] 采集失败:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 磁盘使用率(较慢,单独执行,超时放宽到 30s)
|
||||||
|
try {
|
||||||
|
const diskScript = this._buildDiskScript();
|
||||||
|
const diskCmd = this._buildPowerShellCmd(diskScript);
|
||||||
|
if (DEBUG) console.log("[WinAdapter] 磁盘命令:", diskCmd);
|
||||||
|
raw.disk = await this.execute(diskCmd);
|
||||||
|
if (DEBUG) console.log("[WinAdapter] 磁盘原始输出:", raw.disk);
|
||||||
|
metrics.disk_usage = this._parseDisk(raw.disk);
|
||||||
|
} catch (e) {
|
||||||
|
raw.disk_error = e.message;
|
||||||
|
if (DEBUG) console.error("[WinAdapter] 磁盘采集失败:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 关键服务状态
|
||||||
|
try {
|
||||||
|
const serviceScript = this._buildServiceScript();
|
||||||
|
const cmd = this._buildPowerShellCmd(serviceScript);
|
||||||
|
if (DEBUG) console.log("[WinAdapter] 服务命令:", cmd);
|
||||||
|
raw.services = await this.execute(cmd);
|
||||||
|
if (DEBUG) console.log("[WinAdapter] 服务原始输出:", raw.services);
|
||||||
|
metrics.service_status = this._parseServices(raw.services);
|
||||||
|
} catch (e) {
|
||||||
|
metrics.service_status = [];
|
||||||
|
raw.services_error = e.message;
|
||||||
|
if (DEBUG) console.error("[WinAdapter] 服务采集失败:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速统计:CPU + 内存(使用更快的 WMI 查询)
|
||||||
|
*/
|
||||||
|
_buildFastStatsScript() {
|
||||||
|
return `
|
||||||
|
try { $cpu = (Get-WmiObject Win32_Processor).LoadPercentage; if (-not $cpu) { $cpu = 0 } } catch { $cpu = 0 }
|
||||||
|
try { $os = Get-WmiObject Win32_OperatingSystem; $total = [math]::Round($os.TotalVisibleMemorySize/1MB,0); $free = [math]::Round($os.FreePhysicalMemory/1MB,0); $usage = if ($total -gt 0) { [math]::Round(($total-$free)/$total*100,1) } else { 0 } } catch { $total = 0; $usage = 0; $free = 0 }
|
||||||
|
@{ cpu = $cpu; mem_usage = $usage; mem_total = $total; mem_free = $free } | ConvertTo-Json -Compress
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 磁盘使用率(只查 C: 盘) */
|
||||||
|
_buildDiskScript() {
|
||||||
|
return `
|
||||||
|
try {
|
||||||
|
$disk = Get-WmiObject Win32_LogicalDisk -Filter "DeviceID='C:'"
|
||||||
|
if ($disk -and $disk.Size -gt 0) {
|
||||||
|
$s = [math]::Round($disk.Size/1GB,1); $f = [math]::Round($disk.FreeSpace/1GB,1); $u = [math]::Round(($disk.Size-$disk.FreeSpace)/$disk.Size*100,1)
|
||||||
|
@{ d='C:'; s=$s; f=$f; u=$u } | ConvertTo-Json -Compress
|
||||||
|
} else { 'null' }
|
||||||
|
} catch { 'null' }
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseDisk(stdout) {
|
||||||
|
try {
|
||||||
|
const json = this._extractJson(stdout);
|
||||||
|
if (!json || json === "null" || json === null) return [];
|
||||||
|
return [{
|
||||||
|
drive: json.d || "C:",
|
||||||
|
size_gb: json.s || 0,
|
||||||
|
free_gb: json.f || 0,
|
||||||
|
usage_percent: json.u || 0,
|
||||||
|
}];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 PowerShell 命令:UTF-16LE Base64 编码 + -EncodedCommand
|
||||||
|
* Node.js Buffer.from(str, "utf16le") 是最可靠的编码方式
|
||||||
|
*/
|
||||||
|
_buildPowerShellCmd(script) {
|
||||||
|
// 用 Node.js 原生 Buffer 进行 UTF-16LE 编码,再转 Base64
|
||||||
|
// UTF-16LE 编码(不带 BOM),然后 Base64
|
||||||
|
const utf16le = Buffer.from(script, "utf16le");
|
||||||
|
const encoded = utf16le.toString("base64");
|
||||||
|
return `powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildServiceScript() {
|
||||||
|
return `
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
|
||||||
|
# 关键服务
|
||||||
|
$services = @(
|
||||||
|
"wuauserv", # Windows Update
|
||||||
|
"Spooler", # Print Spooler
|
||||||
|
"TermService", # Remote Desktop
|
||||||
|
"DHCP", # DHCP Client
|
||||||
|
"DNScache" # DNS Client
|
||||||
|
)
|
||||||
|
|
||||||
|
$results = foreach ($svc in $services) {
|
||||||
|
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
|
||||||
|
if ($s) {
|
||||||
|
[PSCustomObject]@{
|
||||||
|
name = $s.Name
|
||||||
|
display_name = $s.DisplayName
|
||||||
|
status = $s.Status.ToString()
|
||||||
|
start_type = $s.StartType.ToString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$results | ConvertTo-Json -Depth 3
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseStats(stdout) {
|
||||||
|
try {
|
||||||
|
const json = this._extractJson(stdout);
|
||||||
|
if (!json) return {};
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
if (json.cpu !== undefined) result.cpu_usage = json.cpu;
|
||||||
|
if (json.mem_usage !== undefined) result.memory_usage = json.mem_usage;
|
||||||
|
if (json.mem_total !== undefined && json.mem_total > 0) {
|
||||||
|
result.memory = {
|
||||||
|
total_mb: json.mem_total,
|
||||||
|
used_mb: Math.round((json.mem_usage / 100) * json.mem_total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseServices(stdout) {
|
||||||
|
try {
|
||||||
|
const json = this._extractJson(stdout);
|
||||||
|
if (!json) return [];
|
||||||
|
return Array.isArray(json) ? json : [json];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 PowerShell 输出中提取 JSON(可能有多余的警告/文本)
|
||||||
|
*/
|
||||||
|
_extractJson(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
const start = text.indexOf("{");
|
||||||
|
const startArr = text.indexOf("[");
|
||||||
|
const realStart = startArr === -1 ? start : (start === -1 ? startArr : Math.min(start, startArr));
|
||||||
|
if (realStart === -1) return null;
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let end = -1;
|
||||||
|
const open = text[realStart];
|
||||||
|
const close = open === "{" ? "}" : "]";
|
||||||
|
|
||||||
|
for (let i = realStart; i < text.length; i++) {
|
||||||
|
if (text[i] === open) depth++;
|
||||||
|
else if (text[i] === close) {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text.substring(realStart, end + 1));
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = WindowsServerAdapter;
|
||||||
280
electron/bridges/inspection/alertRecordStore.cjs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警记录存储
|
||||||
|
*
|
||||||
|
* 告警记录结构:
|
||||||
|
* {
|
||||||
|
* id: "alert_xxx",
|
||||||
|
* rule_id: "rule_cpu_critical",
|
||||||
|
* rule_name: "CPU 使用率过高",
|
||||||
|
* device_id: "host_xxx",
|
||||||
|
* device_name: "核心交换机-01",
|
||||||
|
* metric: "cpu_usage",
|
||||||
|
* metric_value: "85",
|
||||||
|
* threshold: "80",
|
||||||
|
* level: "critical",
|
||||||
|
* message: "核心交换机-01 CPU 使用率 85%,超过阈值 80%",
|
||||||
|
* triggered_at: timestamp,
|
||||||
|
* status: "active",
|
||||||
|
* acknowledged_by: null,
|
||||||
|
* acknowledged_at: null,
|
||||||
|
* resolved_at: null,
|
||||||
|
* resolved_reason: null,
|
||||||
|
* note: null,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { STORAGE_KEY_ALERT_RECORDS, STORAGE_KEY_ALERT_COUNTERS } = require("./storageKeys.cjs");
|
||||||
|
const { ALERT_RECORD_STATUS, ALERT_LEVELS } = require("./types.cjs");
|
||||||
|
|
||||||
|
const MAX_ALERTS = 2000;
|
||||||
|
|
||||||
|
let _alerts = null;
|
||||||
|
let _counters = null; // 防抖动计数器 { "deviceId_ruleId": count }
|
||||||
|
|
||||||
|
function _ensureLoaded(readFn) {
|
||||||
|
if (_alerts === null) {
|
||||||
|
const raw = readFn(STORAGE_KEY_ALERT_RECORDS);
|
||||||
|
_alerts = raw ? _safeParse(raw, []) : [];
|
||||||
|
}
|
||||||
|
if (_counters === null) {
|
||||||
|
const raw = readFn(STORAGE_KEY_ALERT_COUNTERS);
|
||||||
|
_counters = raw ? _safeParse(raw, {}) : {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeParse(text, def) {
|
||||||
|
try { return JSON.parse(text); }
|
||||||
|
catch (e) { return def; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveAlerts(writeFn) {
|
||||||
|
if (_alerts.length > MAX_ALERTS) {
|
||||||
|
// 只保留最近的
|
||||||
|
_alerts = _alerts.sort((a, b) => b.triggered_at - a.triggered_at).slice(0, MAX_ALERTS);
|
||||||
|
}
|
||||||
|
writeFn(STORAGE_KEY_ALERT_RECORDS, JSON.stringify(_alerts));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveCounters(writeFn) {
|
||||||
|
writeFn(STORAGE_KEY_ALERT_COUNTERS, JSON.stringify(_counters));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 告警触发(带防抖动) ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理告警:检查是否满足持续次数,满足则创建/更新告警记录
|
||||||
|
* @param {object} device - 设备信息 { device_id, device_name }
|
||||||
|
* @param {object} rule - 告警规则
|
||||||
|
* @param {number|string} metricValue - 当前指标值
|
||||||
|
* @param {boolean} isTriggered - 是否触发(指标值 > 阈值)
|
||||||
|
* @returns {object|null} 新创建或恢复的告警记录,无变化则返回 null
|
||||||
|
*/
|
||||||
|
function processAlert(readFn, writeFn, device, rule, metricValue, isTriggered) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
|
||||||
|
const counterKey = `${device.device_id}_${rule.id}`;
|
||||||
|
const activeAlert = _findActiveAlert(device.device_id, rule.id);
|
||||||
|
|
||||||
|
if (isTriggered) {
|
||||||
|
// 触发:计数 +1
|
||||||
|
_counters[counterKey] = (_counters[counterKey] || 0) + 1;
|
||||||
|
_saveCounters(writeFn);
|
||||||
|
|
||||||
|
// 达到持续次数且没有活跃告警 → 创建告警
|
||||||
|
if (_counters[counterKey] >= rule.duration && !activeAlert) {
|
||||||
|
const alert = _createAlert(device, rule, metricValue);
|
||||||
|
_alerts.push(alert);
|
||||||
|
_saveAlerts(writeFn);
|
||||||
|
return { ...alert, action: "triggered" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已有活跃告警 → 更新指标值
|
||||||
|
if (activeAlert) {
|
||||||
|
activeAlert.metric_value = String(metricValue);
|
||||||
|
activeAlert.message = _buildMessage(device.device_name, rule, metricValue);
|
||||||
|
_saveAlerts(writeFn);
|
||||||
|
return { ...activeAlert, action: "updated" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
// 未触发:重置计数
|
||||||
|
if (_counters[counterKey]) {
|
||||||
|
delete _counters[counterKey];
|
||||||
|
_saveCounters(writeFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有活跃告警 → 自动恢复
|
||||||
|
if (activeAlert && activeAlert.status === ALERT_RECORD_STATUS.ACTIVE) {
|
||||||
|
activeAlert.status = ALERT_RECORD_STATUS.RESOLVED;
|
||||||
|
activeAlert.resolved_at = Date.now();
|
||||||
|
activeAlert.resolved_reason = "auto_recover";
|
||||||
|
_saveAlerts(writeFn);
|
||||||
|
return { ...activeAlert, action: "resolved" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findActiveAlert(deviceId, ruleId) {
|
||||||
|
return _alerts.find(
|
||||||
|
(a) =>
|
||||||
|
a.device_id === deviceId &&
|
||||||
|
a.rule_id === ruleId &&
|
||||||
|
(a.status === ALERT_RECORD_STATUS.ACTIVE ||
|
||||||
|
a.status === ALERT_RECORD_STATUS.ACKNOWLEDGED)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _createAlert(device, rule, metricValue) {
|
||||||
|
return {
|
||||||
|
id: `alert_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
rule_id: rule.id,
|
||||||
|
rule_name: rule.name,
|
||||||
|
device_id: device.device_id,
|
||||||
|
device_name: device.device_name || device.device_id,
|
||||||
|
metric: rule.metric,
|
||||||
|
metric_value: String(metricValue),
|
||||||
|
threshold: rule.threshold,
|
||||||
|
level: rule.level,
|
||||||
|
message: _buildMessage(device.device_name || device.device_id, rule, metricValue),
|
||||||
|
triggered_at: Date.now(),
|
||||||
|
status: ALERT_RECORD_STATUS.ACTIVE,
|
||||||
|
acknowledged_by: null,
|
||||||
|
acknowledged_at: null,
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_reason: null,
|
||||||
|
note: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildMessage(deviceName, rule, metricValue) {
|
||||||
|
const unit = rule.metric.includes("_usage") ? "%" : "";
|
||||||
|
return `${deviceName} ${rule.name},当前值 ${metricValue}${unit},阈值 ${rule.threshold}${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 告警查询 ----------
|
||||||
|
|
||||||
|
function listAlerts(readFn, options = {}) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
let result = [..._alerts];
|
||||||
|
|
||||||
|
if (options.status) {
|
||||||
|
result = result.filter((a) => a.status === options.status);
|
||||||
|
}
|
||||||
|
if (options.level) {
|
||||||
|
result = result.filter((a) => a.level === options.level);
|
||||||
|
}
|
||||||
|
if (options.device_id) {
|
||||||
|
result = result.filter((a) => a.device_id === options.device_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => b.triggered_at - a.triggered_at).slice(0, options.limit || 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveAlerts(readFn) {
|
||||||
|
return listAlerts(readFn, { status: ALERT_RECORD_STATUS.ACTIVE });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlert(readFn, alertId) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return _alerts.find((a) => a.id === alertId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 告警处理 ----------
|
||||||
|
|
||||||
|
function acknowledgeAlert(readFn, writeFn, alertId, operator = "system") {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
const alert = _alerts.find((a) => a.id === alertId);
|
||||||
|
if (!alert) return null;
|
||||||
|
|
||||||
|
alert.status = ALERT_RECORD_STATUS.ACKNOWLEDGED;
|
||||||
|
alert.acknowledged_by = operator;
|
||||||
|
alert.acknowledged_at = Date.now();
|
||||||
|
_saveAlerts(writeFn);
|
||||||
|
return { ...alert };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAlert(readFn, writeFn, alertId, reason = "manual", operator = "system") {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
const alert = _alerts.find((a) => a.id === alertId);
|
||||||
|
if (!alert) return null;
|
||||||
|
|
||||||
|
alert.status = ALERT_RECORD_STATUS.RESOLVED;
|
||||||
|
alert.resolved_at = Date.now();
|
||||||
|
alert.resolved_reason = reason;
|
||||||
|
alert.acknowledged_by = alert.acknowledged_by || operator;
|
||||||
|
alert.acknowledged_at = alert.acknowledged_at || Date.now();
|
||||||
|
_saveAlerts(writeFn);
|
||||||
|
return { ...alert };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAlert(readFn, writeFn, alertId, note = "") {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
const alert = _alerts.find((a) => a.id === alertId);
|
||||||
|
if (!alert) return null;
|
||||||
|
|
||||||
|
alert.status = ALERT_RECORD_STATUS.CLOSED;
|
||||||
|
alert.note = note;
|
||||||
|
if (!alert.resolved_at) {
|
||||||
|
alert.resolved_at = Date.now();
|
||||||
|
alert.resolved_reason = "closed";
|
||||||
|
}
|
||||||
|
_saveAlerts(writeFn);
|
||||||
|
return { ...alert };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNote(readFn, writeFn, alertId, note) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
const alert = _alerts.find((a) => a.id === alertId);
|
||||||
|
if (!alert) return null;
|
||||||
|
|
||||||
|
alert.note = note;
|
||||||
|
_saveAlerts(writeFn);
|
||||||
|
return { ...alert };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 统计 ----------
|
||||||
|
|
||||||
|
function getAlertStats(readFn) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
|
||||||
|
const active = _alerts.filter((a) => a.status === ALERT_RECORD_STATUS.ACTIVE);
|
||||||
|
return {
|
||||||
|
total: _alerts.length,
|
||||||
|
active: active.length,
|
||||||
|
active_critical: active.filter((a) => a.level === ALERT_LEVELS.CRITICAL).length,
|
||||||
|
active_warning: active.filter((a) => a.level === ALERT_LEVELS.WARNING).length,
|
||||||
|
active_info: active.filter((a) => a.level === ALERT_LEVELS.INFO).length,
|
||||||
|
acknowledged: _alerts.filter((a) => a.status === ALERT_RECORD_STATUS.ACKNOWLEDGED).length,
|
||||||
|
resolved: _alerts.filter((a) => a.status === ALERT_RECORD_STATUS.RESOLVED).length,
|
||||||
|
closed: _alerts.filter((a) => a.status === ALERT_RECORD_STATUS.CLOSED).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 重置(测试用) ----------
|
||||||
|
function _resetForTest() {
|
||||||
|
_alerts = null;
|
||||||
|
_counters = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// 告警触发
|
||||||
|
processAlert,
|
||||||
|
// 查询
|
||||||
|
listAlerts,
|
||||||
|
getActiveAlerts,
|
||||||
|
getAlert,
|
||||||
|
// 处理
|
||||||
|
acknowledgeAlert,
|
||||||
|
resolveAlert,
|
||||||
|
closeAlert,
|
||||||
|
updateNote,
|
||||||
|
// 统计
|
||||||
|
getAlertStats,
|
||||||
|
// 内部
|
||||||
|
_resetForTest,
|
||||||
|
};
|
||||||
543
electron/bridges/inspection/alertRuleStore.cjs
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警规则数据存储
|
||||||
|
*
|
||||||
|
* 告警规则结构:
|
||||||
|
* {
|
||||||
|
* id: "rule_xxx",
|
||||||
|
* name: "CPU 使用率过高",
|
||||||
|
* metric: "cpu_usage",
|
||||||
|
* operator: ">",
|
||||||
|
* threshold: "80",
|
||||||
|
* level: "critical",
|
||||||
|
* duration: 1, // 连续超标次数(防抖动)
|
||||||
|
* scope_type: "all", // all / tier / device / catalog
|
||||||
|
* scope_value: null, // 范围值(tier_key 或 hostId 数组 或 catalog_id)
|
||||||
|
* enabled: true,
|
||||||
|
* created_at: timestamp,
|
||||||
|
* updated_at: timestamp,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { STORAGE_KEY_ALERT_RULES } = require("./storageKeys.cjs");
|
||||||
|
const { OPERATORS, ALERT_LEVELS } = require("./types.cjs");
|
||||||
|
|
||||||
|
let _rules = null;
|
||||||
|
|
||||||
|
function _ensureLoaded(readFn) {
|
||||||
|
if (_rules === null) {
|
||||||
|
const raw = readFn(STORAGE_KEY_ALERT_RULES);
|
||||||
|
_rules = raw ? _safeParse(raw, []) : _getDefaultRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeParse(text, def) {
|
||||||
|
try { return JSON.parse(text); }
|
||||||
|
catch (e) { return def; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _save(writeFn) {
|
||||||
|
writeFn(STORAGE_KEY_ALERT_RULES, JSON.stringify(_rules));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认告警规则(预置)
|
||||||
|
*/
|
||||||
|
function _getDefaultRules() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "rule_cpu_critical",
|
||||||
|
name: "CPU 使用率过高",
|
||||||
|
metric: "cpu_usage",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "80",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "all",
|
||||||
|
scope_value: null,
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_cpu_warning",
|
||||||
|
name: "CPU 使用率异常",
|
||||||
|
metric: "cpu_usage",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "70",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "all",
|
||||||
|
scope_value: null,
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_memory_critical",
|
||||||
|
name: "内存使用率过高",
|
||||||
|
metric: "memory_usage",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "85",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "all",
|
||||||
|
scope_value: null,
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_memory_warning",
|
||||||
|
name: "内存使用率异常",
|
||||||
|
metric: "memory_usage",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "75",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "all",
|
||||||
|
scope_value: null,
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_offline",
|
||||||
|
name: "设备离线",
|
||||||
|
metric: "online",
|
||||||
|
operator: OPERATORS.EQ,
|
||||||
|
threshold: "false",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "all",
|
||||||
|
scope_value: null,
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
// ---------- 磁盘使用率 ----------
|
||||||
|
{
|
||||||
|
id: "rule_disk_critical",
|
||||||
|
name: "磁盘使用率过高",
|
||||||
|
metric: "disk_usage",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "90",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "all",
|
||||||
|
scope_value: null,
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_disk_warning",
|
||||||
|
name: "磁盘使用率异常",
|
||||||
|
metric: "disk_usage",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "80",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "all",
|
||||||
|
scope_value: null,
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
// ---------- 网络设备通用(交换机/防火墙) ----------
|
||||||
|
{
|
||||||
|
id: "rule_netdev_temp_critical",
|
||||||
|
name: "设备温度过高",
|
||||||
|
metric: "temperature",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "60",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_h3c_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_netdev_power_critical",
|
||||||
|
name: "电源模块故障",
|
||||||
|
metric: "power_failure_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "0",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_h3c_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_netdev_fan_warning",
|
||||||
|
name: "风扇模块故障",
|
||||||
|
metric: "fan_failure_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "0",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_h3c_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_netdev_portdown_warning",
|
||||||
|
name: "端口 Down 数过多",
|
||||||
|
metric: "port_down_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "2",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_h3c_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_netdev_cpu_warning",
|
||||||
|
name: "交换机 CPU 使用率过高",
|
||||||
|
metric: "cpu_usage",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "75",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_h3c_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_netdev_memory_warning",
|
||||||
|
name: "交换机内存使用率过高",
|
||||||
|
metric: "memory_usage",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "80",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_h3c_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
// ---------- Cisco 交换机 ----------
|
||||||
|
{
|
||||||
|
id: "rule_cisco_temp_critical",
|
||||||
|
name: "Cisco 交换机温度过高",
|
||||||
|
metric: "temperature",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "60",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "cisco_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_cisco_power_critical",
|
||||||
|
name: "Cisco 电源模块故障",
|
||||||
|
metric: "power_failure_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "0",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "cisco_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_cisco_fan_warning",
|
||||||
|
name: "Cisco 风扇模块故障",
|
||||||
|
metric: "fan_failure_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "0",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "cisco_switch",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
// ---------- 华为防火墙 ----------
|
||||||
|
{
|
||||||
|
id: "rule_hwfw_temp_critical",
|
||||||
|
name: "华为防火墙温度过高",
|
||||||
|
metric: "temperature",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "60",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_firewall",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_hwfw_power_critical",
|
||||||
|
name: "华为防火墙电源故障",
|
||||||
|
metric: "power_failure_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "0",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_firewall",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_hwfw_fan_warning",
|
||||||
|
name: "华为防火墙风扇故障",
|
||||||
|
metric: "fan_failure_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "0",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "huawei_firewall",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
// ---------- 飞塔防火墙 ----------
|
||||||
|
{
|
||||||
|
id: "rule_fortinet_temp_critical",
|
||||||
|
name: "飞塔防火墙温度过高",
|
||||||
|
metric: "temperature",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "60",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 2,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "fortinet_firewall",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_fortinet_power_critical",
|
||||||
|
name: "飞塔防火墙电源故障",
|
||||||
|
metric: "power_failure_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "0",
|
||||||
|
level: ALERT_LEVELS.CRITICAL,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "fortinet_firewall",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rule_fortinet_fan_warning",
|
||||||
|
name: "飞塔防火墙风扇故障",
|
||||||
|
metric: "fan_failure_count",
|
||||||
|
operator: OPERATORS.GT,
|
||||||
|
threshold: "0",
|
||||||
|
level: ALERT_LEVELS.WARNING,
|
||||||
|
duration: 1,
|
||||||
|
scope_type: "catalog",
|
||||||
|
scope_value: "fortinet_firewall",
|
||||||
|
enabled: true,
|
||||||
|
preset: true,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CRUD ----------
|
||||||
|
|
||||||
|
function listRules(readFn) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return [..._rules];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRule(readFn, ruleId) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return _rules.find((r) => r.id === ruleId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRule(readFn, writeFn, data) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
id: `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: data.name,
|
||||||
|
metric: data.metric,
|
||||||
|
operator: data.operator,
|
||||||
|
threshold: String(data.threshold),
|
||||||
|
level: data.level,
|
||||||
|
duration: data.duration || 1,
|
||||||
|
scope_type: data.scope_type || "all",
|
||||||
|
scope_value: data.scope_value || null,
|
||||||
|
enabled: data.enabled !== false,
|
||||||
|
preset: false,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_rules.push(rule);
|
||||||
|
_save(writeFn);
|
||||||
|
return { ...rule };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRule(readFn, writeFn, ruleId, updates) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
|
||||||
|
const idx = _rules.findIndex((r) => r.id === ruleId);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
|
||||||
|
_rules[idx] = {
|
||||||
|
..._rules[idx],
|
||||||
|
...updates,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_save(writeFn);
|
||||||
|
return { ..._rules[idx] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRule(readFn, writeFn, ruleId) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
|
||||||
|
const idx = _rules.findIndex((r) => r.id === ruleId);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
|
||||||
|
_rules.splice(idx, 1);
|
||||||
|
_save(writeFn);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRule(readFn, writeFn, ruleId, enabled) {
|
||||||
|
return updateRule(readFn, writeFn, ruleId, { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 告警判断 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指标值是否触发规则
|
||||||
|
* @param {number|string} value - 指标值
|
||||||
|
* @param {string} operator - 比较符
|
||||||
|
* @param {string} threshold - 阈值
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function evaluateRule(value, operator, threshold) {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
|
||||||
|
// disk_usage 是数组,检查任意挂载点是否超标
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const numThreshold = parseFloat(threshold);
|
||||||
|
if (isNaN(numThreshold)) return false;
|
||||||
|
// 数组中每个元素是 { filesystem, usage_percent, mount }
|
||||||
|
return value.some((d) => {
|
||||||
|
const usage = typeof d === "object" ? d.usage_percent : parseFloat(d);
|
||||||
|
return !isNaN(usage) && usage > numThreshold;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数字比较
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
const numThreshold = parseFloat(threshold);
|
||||||
|
|
||||||
|
if (!isNaN(numValue) && !isNaN(numThreshold)) {
|
||||||
|
switch (operator) {
|
||||||
|
case OPERATORS.GT: return numValue > numThreshold;
|
||||||
|
case OPERATORS.LT: return numValue < numThreshold;
|
||||||
|
case OPERATORS.GTE: return numValue >= numThreshold;
|
||||||
|
case OPERATORS.LTE: return numValue <= numThreshold;
|
||||||
|
case OPERATORS.EQ: return numValue === numThreshold;
|
||||||
|
case OPERATORS.NE: return numValue !== numThreshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符串比较
|
||||||
|
const strValue = String(value);
|
||||||
|
const strThreshold = String(threshold);
|
||||||
|
switch (operator) {
|
||||||
|
case OPERATORS.EQ: return strValue === strThreshold;
|
||||||
|
case OPERATORS.NE: return strValue !== strThreshold;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备适用的所有规则
|
||||||
|
* @param {object} deviceExt - 设备扩展信息
|
||||||
|
* @returns {Array} 适用的规则列表
|
||||||
|
*/
|
||||||
|
function getRulesForDevice(readFn, deviceExt) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
|
||||||
|
return _rules.filter((rule) => {
|
||||||
|
if (!rule.enabled) return false;
|
||||||
|
|
||||||
|
switch (rule.scope_type) {
|
||||||
|
case "all":
|
||||||
|
return true;
|
||||||
|
case "tier":
|
||||||
|
return deviceExt.tier && deviceExt.tier === rule.scope_value;
|
||||||
|
case "device":
|
||||||
|
return Array.isArray(rule.scope_value) && rule.scope_value.includes(deviceExt.host_id);
|
||||||
|
case "catalog":
|
||||||
|
return deviceExt.catalog_id === rule.scope_value;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 重置(测试用) ----------
|
||||||
|
function _resetForTest() {
|
||||||
|
_rules = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listRules,
|
||||||
|
getRule,
|
||||||
|
createRule,
|
||||||
|
updateRule,
|
||||||
|
deleteRule,
|
||||||
|
toggleRule,
|
||||||
|
evaluateRule,
|
||||||
|
getRulesForDevice,
|
||||||
|
_resetForTest,
|
||||||
|
};
|
||||||
189
electron/bridges/inspection/deviceCatalogStore.cjs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备类型数据模型 + 预置数据
|
||||||
|
*
|
||||||
|
* 设备类型(device_catalog):定义一类设备的采集能力和默认配置。
|
||||||
|
* 预置 6 种设备类型,用户可在界面上新增。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { DEVICE_CATALOG_TYPES, ALERT_LEVELS, DEVICE_TIERS } = require("./types.cjs");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认告警阈值 - 用于设备类型的预置配置
|
||||||
|
* 格式:{ metric, operator, threshold, level, duration }
|
||||||
|
*/
|
||||||
|
const DEFAULT_THRESHOLDS = {
|
||||||
|
linux: [
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "80", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "70", level: ALERT_LEVELS.WARNING, duration: 2 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "85", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "75", level: ALERT_LEVELS.WARNING, duration: 2 },
|
||||||
|
{ metric: "online", operator: "==", threshold: "false", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
],
|
||||||
|
windows: [
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "80", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "70", level: ALERT_LEVELS.WARNING, duration: 2 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "85", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "75", level: ALERT_LEVELS.WARNING, duration: 2 },
|
||||||
|
{ metric: "online", operator: "==", threshold: "false", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
],
|
||||||
|
huawei_h3c_switch: [
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "80", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "70", level: ALERT_LEVELS.WARNING, duration: 2 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "85", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "75", level: ALERT_LEVELS.WARNING, duration: 2 },
|
||||||
|
{ metric: "online", operator: "==", threshold: "false", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
],
|
||||||
|
cisco_switch: [
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "80", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "70", level: ALERT_LEVELS.WARNING, duration: 2 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "85", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "75", level: ALERT_LEVELS.WARNING, duration: 2 },
|
||||||
|
{ metric: "online", operator: "==", threshold: "false", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
],
|
||||||
|
huawei_firewall: [
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "80", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "85", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "online", operator: "==", threshold: "false", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
],
|
||||||
|
fortinet_firewall: [
|
||||||
|
{ metric: "cpu_usage", operator: ">", threshold: "80", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "memory_usage", operator: ">", threshold: "85", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
{ metric: "online", operator: "==", threshold: "false", level: ALERT_LEVELS.CRITICAL, duration: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预置设备类型列表
|
||||||
|
* 每条记录对应一条 device_catalog 数据
|
||||||
|
*/
|
||||||
|
const PRESET_DEVICE_CATALOG = [
|
||||||
|
{
|
||||||
|
id: "linux",
|
||||||
|
type_key: DEVICE_CATALOG_TYPES.LINUX,
|
||||||
|
name: "Linux 服务器",
|
||||||
|
vendor: "Linux",
|
||||||
|
adapter_class: "LinuxServerAdapter",
|
||||||
|
supported_metrics: [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"disk_usage",
|
||||||
|
"load_avg",
|
||||||
|
"uptime",
|
||||||
|
"process_count",
|
||||||
|
"online",
|
||||||
|
],
|
||||||
|
default_thresholds: DEFAULT_THRESHOLDS.linux,
|
||||||
|
default_interval: 5, // 默认 5 分钟
|
||||||
|
description: "通用 Linux 服务器,支持 Ubuntu/CentOS/Debian/Alpine 等发行版",
|
||||||
|
preset: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "windows",
|
||||||
|
type_key: DEVICE_CATALOG_TYPES.WINDOWS,
|
||||||
|
name: "Windows 服务器",
|
||||||
|
vendor: "Microsoft",
|
||||||
|
adapter_class: "WindowsServerAdapter",
|
||||||
|
supported_metrics: [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"disk_usage",
|
||||||
|
"uptime",
|
||||||
|
"process_count",
|
||||||
|
"service_status",
|
||||||
|
"online",
|
||||||
|
],
|
||||||
|
default_thresholds: DEFAULT_THRESHOLDS.windows,
|
||||||
|
default_interval: 5,
|
||||||
|
description: "Windows Server 2012/2016/2019/2022,通过 SSH + PowerShell 采集",
|
||||||
|
preset: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "huawei_h3c_switch",
|
||||||
|
type_key: DEVICE_CATALOG_TYPES.HUAWEI_H3C_SWITCH,
|
||||||
|
name: "华为/H3C 交换机",
|
||||||
|
vendor: "华为/H3C",
|
||||||
|
adapter_class: "HuaweiH3CSwitchAdapter",
|
||||||
|
supported_metrics: [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"port_status",
|
||||||
|
"power_status",
|
||||||
|
"fan_status",
|
||||||
|
"device_info",
|
||||||
|
"online",
|
||||||
|
],
|
||||||
|
default_thresholds: DEFAULT_THRESHOLDS.huawei_h3c_switch,
|
||||||
|
default_interval: 10, // 交换机默认 10 分钟
|
||||||
|
description: "华为 S3000/S5000/S7000/S9000 系列、H3C S5100/S5500 等交换机",
|
||||||
|
preset: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cisco_switch",
|
||||||
|
type_key: DEVICE_CATALOG_TYPES.CISCO_SWITCH,
|
||||||
|
name: "Cisco 交换机",
|
||||||
|
vendor: "Cisco",
|
||||||
|
adapter_class: "CiscoSwitchAdapter",
|
||||||
|
supported_metrics: [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"port_status",
|
||||||
|
"power_status",
|
||||||
|
"fan_status",
|
||||||
|
"online",
|
||||||
|
],
|
||||||
|
default_thresholds: DEFAULT_THRESHOLDS.cisco_switch,
|
||||||
|
default_interval: 10,
|
||||||
|
description: "Cisco Catalyst 2960/3650/3850/4500/6500 等交换机",
|
||||||
|
preset: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "huawei_firewall",
|
||||||
|
type_key: DEVICE_CATALOG_TYPES.HUAWEI_FIREWALL,
|
||||||
|
name: "华为防火墙",
|
||||||
|
vendor: "华为",
|
||||||
|
adapter_class: "HuaweiFirewallAdapter",
|
||||||
|
supported_metrics: [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"session_count",
|
||||||
|
"online",
|
||||||
|
],
|
||||||
|
default_thresholds: DEFAULT_THRESHOLDS.huawei_firewall,
|
||||||
|
default_interval: 5, // 防火墙重要,默认 5 分钟
|
||||||
|
description: "华为 USG 系列防火墙",
|
||||||
|
preset: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fortinet_firewall",
|
||||||
|
type_key: DEVICE_CATALOG_TYPES.FORTINET_FIREWALL,
|
||||||
|
name: "飞塔防火墙",
|
||||||
|
vendor: "Fortinet",
|
||||||
|
adapter_class: "FortinetFirewallAdapter",
|
||||||
|
supported_metrics: [
|
||||||
|
"cpu_usage",
|
||||||
|
"memory_usage",
|
||||||
|
"session_count",
|
||||||
|
"online",
|
||||||
|
],
|
||||||
|
default_thresholds: DEFAULT_THRESHOLDS.fortinet_firewall,
|
||||||
|
default_interval: 5,
|
||||||
|
description: "Fortinet FortiGate 系列防火墙",
|
||||||
|
preset: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预置设备类型数据
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
function getPresetCatalog() {
|
||||||
|
return JSON.parse(JSON.stringify(PRESET_DEVICE_CATALOG));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PRESET_DEVICE_CATALOG,
|
||||||
|
DEFAULT_THRESHOLDS,
|
||||||
|
getPresetCatalog,
|
||||||
|
};
|
||||||
276
electron/bridges/inspection/deviceStore.cjs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备扩展数据存储(巡检相关配置)
|
||||||
|
*
|
||||||
|
* 关联 NetMesh 的主机数据,扩展巡检相关字段:
|
||||||
|
* - 设备类型(catalog_id)
|
||||||
|
* - 设备层级(core/aggregation/access)
|
||||||
|
* - 巡检周期
|
||||||
|
* - 启用状态
|
||||||
|
* - 自定义告警阈值
|
||||||
|
* - 上次巡检信息
|
||||||
|
*
|
||||||
|
* 周期优先级(从高到低):
|
||||||
|
* 1. 设备独立设置 inspection_interval
|
||||||
|
* 2. 设备层级的默认周期 DEVICE_TIER_DEFAULT_INTERVAL
|
||||||
|
* 3. 系统默认周期 SYS_CONFIG_DEFAULTS.inspection_default_interval
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
STORAGE_KEY_DEVICE_EXTENSION,
|
||||||
|
STORAGE_KEY_SYS_CONFIG,
|
||||||
|
} = require("./storageKeys.cjs");
|
||||||
|
const {
|
||||||
|
DEVICE_TIERS,
|
||||||
|
DEVICE_TIER_LABELS,
|
||||||
|
DEVICE_TIER_DEFAULT_INTERVAL,
|
||||||
|
SYS_CONFIG_DEFAULTS,
|
||||||
|
} = require("./types.cjs");
|
||||||
|
const { getPresetCatalog } = require("./deviceCatalogStore.cjs");
|
||||||
|
|
||||||
|
// ---------- 内存缓存 ----------
|
||||||
|
let _extensions = null; // { [hostId]: { ... } }
|
||||||
|
let _sysConfig = null; // { inspection_default_interval: 5, ... }
|
||||||
|
let _catalog = null; // 设备类型列表
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从存储加载数据(首次调用时加载,之后用缓存)
|
||||||
|
* @param {function} readFn - 读取函数 (key) => rawString
|
||||||
|
*/
|
||||||
|
function _ensureLoaded(readFn) {
|
||||||
|
if (_extensions === null) {
|
||||||
|
const raw = readFn(STORAGE_KEY_DEVICE_EXTENSION);
|
||||||
|
_extensions = raw ? _safeParse(raw, {}) : {};
|
||||||
|
}
|
||||||
|
if (_sysConfig === null) {
|
||||||
|
const raw = readFn(STORAGE_KEY_SYS_CONFIG);
|
||||||
|
_sysConfig = raw ? _safeParse(raw, {}) : { ...SYS_CONFIG_DEFAULTS };
|
||||||
|
// 合并默认值
|
||||||
|
for (const k of Object.keys(SYS_CONFIG_DEFAULTS)) {
|
||||||
|
if (_sysConfig[k] === undefined) _sysConfig[k] = SYS_CONFIG_DEFAULTS[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_catalog === null) {
|
||||||
|
_catalog = getPresetCatalog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeParse(text, def) {
|
||||||
|
try { return JSON.parse(text); }
|
||||||
|
catch (e) { return def; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _save(writeFn, key, data) {
|
||||||
|
writeFn(key, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 系统配置 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统配置
|
||||||
|
*/
|
||||||
|
function getSysConfig(readFn) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return { ..._sysConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新系统配置
|
||||||
|
*/
|
||||||
|
function updateSysConfig(readFn, writeFn, updates) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
_sysConfig = { ..._sysConfig, ...updates };
|
||||||
|
_save(writeFn, STORAGE_KEY_SYS_CONFIG, _sysConfig);
|
||||||
|
return { ..._sysConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 设备扩展 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有设备扩展配置
|
||||||
|
*/
|
||||||
|
function getAllExtensions(readFn) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return { ..._extensions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个设备的扩展配置
|
||||||
|
*/
|
||||||
|
function getExtension(readFn, hostId) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return _extensions[hostId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建/更新设备扩展配置
|
||||||
|
* @param {string} hostId - NetMesh 主机 ID
|
||||||
|
* @param {object} data - 扩展配置
|
||||||
|
* @param {string} data.catalog_id - 设备类型 ID
|
||||||
|
* @param {string} [data.tier] - 设备层级 core/aggregation/access
|
||||||
|
* @param {number} [data.inspection_interval] - 巡检周期(分钟),null 表示继承
|
||||||
|
* @param {boolean} [data.inspection_enabled] - 是否启用巡检
|
||||||
|
* @param {object} [data.custom_thresholds] - 自定义告警阈值
|
||||||
|
* @param {string[]} [data.inspection_tags] - 巡检标签
|
||||||
|
* @param {string} [data.hostname] - 主机名(IP 或域名),用于调度器关联 vault host SSH 配置
|
||||||
|
*/
|
||||||
|
function upsertExtension(readFn, writeFn, hostId, data) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
|
||||||
|
const existing = _extensions[hostId] || {
|
||||||
|
host_id: hostId,
|
||||||
|
hostname: null,
|
||||||
|
catalog_id: "",
|
||||||
|
tier: null,
|
||||||
|
inspection_interval: null,
|
||||||
|
inspection_enabled: false,
|
||||||
|
custom_thresholds: [],
|
||||||
|
inspection_tags: [],
|
||||||
|
last_scan_at: null,
|
||||||
|
last_scan_status: null,
|
||||||
|
last_scan_error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
_extensions[hostId] = {
|
||||||
|
...existing,
|
||||||
|
...data,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_save(writeFn, STORAGE_KEY_DEVICE_EXTENSION, _extensions);
|
||||||
|
return { ..._extensions[hostId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除设备扩展配置
|
||||||
|
*/
|
||||||
|
function deleteExtension(readFn, writeFn, hostId) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
if (_extensions[hostId]) {
|
||||||
|
delete _extensions[hostId];
|
||||||
|
_save(writeFn, STORAGE_KEY_DEVICE_EXTENSION, _extensions);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算设备的实际巡检周期(考虑三级继承)
|
||||||
|
* @param {string} hostId
|
||||||
|
* @returns {number} 周期(分钟)
|
||||||
|
*/
|
||||||
|
function getEffectiveInterval(readFn, hostId) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
const ext = _extensions[hostId];
|
||||||
|
if (!ext) return _sysConfig.inspection_default_interval;
|
||||||
|
|
||||||
|
// 1. 设备独立设置
|
||||||
|
if (typeof ext.inspection_interval === "number" && ext.inspection_interval > 0) {
|
||||||
|
return ext.inspection_interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 设备层级默认
|
||||||
|
if (ext.tier && DEVICE_TIER_DEFAULT_INTERVAL[ext.tier]) {
|
||||||
|
return DEVICE_TIER_DEFAULT_INTERVAL[ext.tier];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 系统默认
|
||||||
|
return _sysConfig.inspection_default_interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有启用了巡检的设备列表(带有效周期)
|
||||||
|
*/
|
||||||
|
function getEnabledDevices(readFn) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (const hostId of Object.keys(_extensions)) {
|
||||||
|
const ext = _extensions[hostId];
|
||||||
|
if (ext.inspection_enabled) {
|
||||||
|
result.push({
|
||||||
|
...ext,
|
||||||
|
effective_interval: getEffectiveInterval(readFn, hostId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新上次巡检信息
|
||||||
|
*/
|
||||||
|
function updateLastScan(readFn, writeFn, hostId, status, error = null) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
if (!_extensions[hostId]) return false;
|
||||||
|
|
||||||
|
_extensions[hostId].last_scan_at = Date.now();
|
||||||
|
_extensions[hostId].last_scan_status = status;
|
||||||
|
_extensions[hostId].last_scan_error = error;
|
||||||
|
|
||||||
|
_save(writeFn, STORAGE_KEY_DEVICE_EXTENSION, _extensions);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 设备类型 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备类型列表
|
||||||
|
*/
|
||||||
|
function getCatalog() {
|
||||||
|
if (_catalog === null) {
|
||||||
|
_catalog = getPresetCatalog();
|
||||||
|
}
|
||||||
|
return [..._catalog];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 获取设备类型
|
||||||
|
*/
|
||||||
|
function getCatalogById(catalogId) {
|
||||||
|
const list = getCatalog();
|
||||||
|
return list.find((c) => c.id === catalogId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 设备层级 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备层级列表(带中文名称和默认周期)
|
||||||
|
*/
|
||||||
|
function getTiers() {
|
||||||
|
return [
|
||||||
|
{ key: DEVICE_TIERS.CORE, label: DEVICE_TIER_LABELS.core, default_interval: DEVICE_TIER_DEFAULT_INTERVAL.core },
|
||||||
|
{ key: DEVICE_TIERS.AGGREGATION, label: DEVICE_TIER_LABELS.aggregation, default_interval: DEVICE_TIER_DEFAULT_INTERVAL.aggregation },
|
||||||
|
{ key: DEVICE_TIERS.ACCESS, label: DEVICE_TIER_LABELS.access, default_interval: DEVICE_TIER_DEFAULT_INTERVAL.access },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 重置(测试用) ----------
|
||||||
|
function _resetForTest() {
|
||||||
|
_extensions = null;
|
||||||
|
_sysConfig = null;
|
||||||
|
_catalog = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// 系统配置
|
||||||
|
getSysConfig,
|
||||||
|
updateSysConfig,
|
||||||
|
// 设备扩展
|
||||||
|
getAllExtensions,
|
||||||
|
getExtension,
|
||||||
|
upsertExtension,
|
||||||
|
deleteExtension,
|
||||||
|
getEffectiveInterval,
|
||||||
|
getEnabledDevices,
|
||||||
|
updateLastScan,
|
||||||
|
// 设备类型
|
||||||
|
getCatalog,
|
||||||
|
getCatalogById,
|
||||||
|
// 设备层级
|
||||||
|
getTiers,
|
||||||
|
// 内部
|
||||||
|
_resetForTest,
|
||||||
|
};
|
||||||
502
electron/bridges/inspection/index.cjs
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检模块统一入口
|
||||||
|
*
|
||||||
|
* 提供 IPC handler 注册函数,供主进程注册。
|
||||||
|
* 渲染进程通过 IPC 调用巡检相关功能。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const deviceStore = require("./deviceStore.cjs");
|
||||||
|
const alertRuleStore = require("./alertRuleStore.cjs");
|
||||||
|
const inspectionResultStore = require("./inspectionResultStore.cjs");
|
||||||
|
const alertRecordStore = require("./alertRecordStore.cjs");
|
||||||
|
const adapterRegistry = require("./adapterRegistry.cjs");
|
||||||
|
const InspectionScheduler = require("./inspectionScheduler.cjs");
|
||||||
|
const { execSshCommand } = require("./inspectionExec.cjs");
|
||||||
|
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||||
|
const {
|
||||||
|
ALERT_LEVEL_LABELS,
|
||||||
|
DEVICE_TIER_LABELS,
|
||||||
|
INSPECTION_STATUS_LABELS,
|
||||||
|
ALERT_RECORD_STATUS_LABELS,
|
||||||
|
OPERATOR_LABELS,
|
||||||
|
DEVICE_CATALOG_LABELS,
|
||||||
|
METRIC_LABELS,
|
||||||
|
INSPECTION_STATUS,
|
||||||
|
} = require("./types.cjs");
|
||||||
|
|
||||||
|
let _scheduler = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取标签映射(前端展示用)
|
||||||
|
*/
|
||||||
|
function getLabels() {
|
||||||
|
return {
|
||||||
|
alert_levels: ALERT_LEVEL_LABELS,
|
||||||
|
device_tiers: DEVICE_TIER_LABELS,
|
||||||
|
inspection_status: INSPECTION_STATUS_LABELS,
|
||||||
|
alert_record_status: ALERT_RECORD_STATUS_LABELS,
|
||||||
|
operators: OPERATOR_LABELS,
|
||||||
|
device_catalog: DEVICE_CATALOG_LABELS,
|
||||||
|
metrics: METRIC_LABELS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册巡检模块的 IPC handlers
|
||||||
|
*
|
||||||
|
* @param {object} ipcMain - Electron ipcMain
|
||||||
|
* @param {object} options
|
||||||
|
* @param {function} options.readStorage - 读取本地存储 (key) => string
|
||||||
|
* @param {function} options.writeStorage - 写入本地存储 (key, value) => void
|
||||||
|
*/
|
||||||
|
function registerInspectionHandlers(ipcMain, options) {
|
||||||
|
const { readStorage, writeStorage, sessions } = options;
|
||||||
|
|
||||||
|
const readFn = (key) => readStorage(key);
|
||||||
|
const writeFn = (key, val) => writeStorage(key, val);
|
||||||
|
|
||||||
|
// 根据主机地址查找会话(用于复用终端 SSH 连接)
|
||||||
|
function findSessionByHost(hostname) {
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
|
if (DEBUG) console.log("[Inspection] 查找会话,总会话数:", sessions?.size || 0);
|
||||||
|
if (!sessions) {
|
||||||
|
if (DEBUG) console.log("[Inspection] sessions 未初始化");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
if (!session) continue;
|
||||||
|
// SSH 会话才有 conn
|
||||||
|
if (!session.conn) continue;
|
||||||
|
// 匹配主机地址
|
||||||
|
const sessionHost = session.hostname || session.host || "";
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[Inspection] SSH会话:", sessionHost, "| 目标:", hostname);
|
||||||
|
}
|
||||||
|
if (sessionHost === hostname) {
|
||||||
|
if (DEBUG) console.log("[Inspection] 匹配到会话:", sessionHost);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 标签映射 ----------
|
||||||
|
ipcMain.handle("inspection:getLabels", () => {
|
||||||
|
return getLabels();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 系统配置 ----------
|
||||||
|
ipcMain.handle("inspection:getSysConfig", () => {
|
||||||
|
return deviceStore.getSysConfig(readFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:updateSysConfig", (_e, updates) => {
|
||||||
|
return deviceStore.updateSysConfig(readFn, writeFn, updates);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 设备类型 ----------
|
||||||
|
ipcMain.handle("inspection:getCatalog", () => {
|
||||||
|
return deviceStore.getCatalog();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getTiers", () => {
|
||||||
|
return deviceStore.getTiers();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getAdapters", () => {
|
||||||
|
return adapterRegistry.getRegisteredAdapters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 设备扩展配置 ----------
|
||||||
|
ipcMain.handle("inspection:getAllExtensions", () => {
|
||||||
|
return deviceStore.getAllExtensions(readFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getExtension", (_e, hostId) => {
|
||||||
|
return deviceStore.getExtension(readFn, hostId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:upsertExtension", (_e, hostId, data) => {
|
||||||
|
return deviceStore.upsertExtension(readFn, writeFn, hostId, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:deleteExtension", (_e, hostId) => {
|
||||||
|
return deviceStore.deleteExtension(readFn, writeFn, hostId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getEffectiveInterval", (_e, hostId) => {
|
||||||
|
return deviceStore.getEffectiveInterval(readFn, hostId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getEnabledDevices", () => {
|
||||||
|
return deviceStore.getEnabledDevices(readFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 告警规则 ----------
|
||||||
|
ipcMain.handle("inspection:listRules", () => {
|
||||||
|
return alertRuleStore.listRules(readFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getRule", (_e, ruleId) => {
|
||||||
|
return alertRuleStore.getRule(readFn, ruleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:createRule", (_e, data) => {
|
||||||
|
return alertRuleStore.createRule(readFn, writeFn, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:updateRule", (_e, ruleId, updates) => {
|
||||||
|
return alertRuleStore.updateRule(readFn, writeFn, ruleId, updates);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:deleteRule", (_e, ruleId) => {
|
||||||
|
return alertRuleStore.deleteRule(readFn, writeFn, ruleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:toggleRule", (_e, ruleId, enabled) => {
|
||||||
|
return alertRuleStore.toggleRule(readFn, writeFn, ruleId, enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 巡检结果 ----------
|
||||||
|
ipcMain.handle("inspection:getLatestAll", () => {
|
||||||
|
return inspectionResultStore.getLatestAll(readFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getLatestByDevice", (_e, hostId, limit) => {
|
||||||
|
return inspectionResultStore.getLatestByDevice(readFn, hostId, limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getRecentResults", (_e, limit) => {
|
||||||
|
return inspectionResultStore.getRecent(readFn, limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 单次巡检执行(前端传入完整主机配置) ----------
|
||||||
|
ipcMain.handle("inspection:runInspection", async (_e, payload) => {
|
||||||
|
const { hostId, hostConfig, catalogId } = payload || {};
|
||||||
|
|
||||||
|
if (!hostId || !hostConfig) {
|
||||||
|
return { success: false, error: "缺少主机配置" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[Inspection] 巡检配置:", JSON.stringify({
|
||||||
|
host: hostConfig.hostname || hostConfig.address,
|
||||||
|
port: hostConfig.port || 22,
|
||||||
|
username: hostConfig.username,
|
||||||
|
hasPassword: !!hostConfig.password,
|
||||||
|
hasPrivateKey: !!hostConfig.privateKeyContent,
|
||||||
|
hasKeyId: !!hostConfig.identityFileId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConfig = {
|
||||||
|
host: hostConfig.hostname || hostConfig.address,
|
||||||
|
port: hostConfig.port || 22,
|
||||||
|
username: hostConfig.username,
|
||||||
|
password: hostConfig.password,
|
||||||
|
privateKey: hostConfig.privateKeyContent,
|
||||||
|
passphrase: hostConfig.passphrase,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sshConfig.host || !sshConfig.username) {
|
||||||
|
return { success: false, error: "主机地址或用户名缺失" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result = {
|
||||||
|
id: `ins_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
host_id: hostId,
|
||||||
|
catalog_id: catalogId || "linux_server",
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: 0,
|
||||||
|
status: INSPECTION_STATUS.SUCCESS,
|
||||||
|
metrics: {},
|
||||||
|
raw: {},
|
||||||
|
error: null,
|
||||||
|
alerts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构造 execFn — 优先复用终端会话的 SSH 连接
|
||||||
|
const execFn = async (command, options = {}) => {
|
||||||
|
const timeoutMs = options.timeoutMs || 60000;
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
|
|
||||||
|
// 1. 先尝试复用终端会话的连接
|
||||||
|
const session = findSessionByHost(sshConfig.host);
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[Inspection] 查找会话:", sshConfig.host,
|
||||||
|
"| 找到会话:", session ? "是" : "否",
|
||||||
|
"| 有连接:", session?.conn ? "是" : "否");
|
||||||
|
}
|
||||||
|
if (session && session.conn) {
|
||||||
|
try {
|
||||||
|
const result = await executeBoundedSshCommand(session.conn, command, {
|
||||||
|
openingTimeoutMs: timeoutMs,
|
||||||
|
runTimeoutMs: timeoutMs,
|
||||||
|
maxOutputBytes: 100 * 1024,
|
||||||
|
});
|
||||||
|
if (DEBUG) console.log("[Inspection] 复用会话成功, stdout:", result.stdout?.slice(0, 100));
|
||||||
|
return result.stdout + (result.stderr ? "\n" + result.stderr : "");
|
||||||
|
} catch (e) {
|
||||||
|
// 终端连接失败,回退到新建连接
|
||||||
|
if (DEBUG) console.log("[Inspection] 复用会话失败,回退:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 回退:新建 SSH 连接
|
||||||
|
return await execSshCommand(sshConfig, command, {
|
||||||
|
connectTimeoutMs: 10000,
|
||||||
|
execTimeoutMs: timeoutMs,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构造批量执行函数(一次连接执行多条命令)
|
||||||
|
const execCommandsFn = async (commands, options = {}) => {
|
||||||
|
const { execSshCommands } = require("./inspectionExec.cjs");
|
||||||
|
return await execSshCommands(sshConfig, commands, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取设备类型对应的采集器类名
|
||||||
|
const catalogs = deviceStore.getCatalog();
|
||||||
|
const catalog = catalogs.find((c) => c.id === catalogId);
|
||||||
|
const adapterClassName = catalog?.adapter_class || "LinuxServerAdapter";
|
||||||
|
|
||||||
|
// 创建采集器实例(注入 execFn 和 execCommandsFn)
|
||||||
|
const adapter = adapterRegistry.createAdapter(adapterClassName, sshConfig, {
|
||||||
|
execFn,
|
||||||
|
execCommands: execCommandsFn,
|
||||||
|
timeoutMs: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adapter) {
|
||||||
|
throw new Error("采集器加载失败: " + adapterClassName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectResult = await adapter.collect();
|
||||||
|
|
||||||
|
result.metrics = collectResult.metrics || {};
|
||||||
|
result.raw = collectResult.raw || {};
|
||||||
|
|
||||||
|
if (!collectResult.online) {
|
||||||
|
result.status = collectResult.status || INSPECTION_STATUS.FAILED;
|
||||||
|
result.error = collectResult.error || "设备离线或连接失败";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
result.status = INSPECTION_STATUS.FAILED;
|
||||||
|
result.error = err.message;
|
||||||
|
console.error("[Inspection] runInspection error:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.end_time = Date.now();
|
||||||
|
const duration = result.end_time - result.start_time;
|
||||||
|
|
||||||
|
// 构造设备信息(用于告警记录)
|
||||||
|
const deviceInfo = {
|
||||||
|
device_id: hostId,
|
||||||
|
device_name: hostConfig.label || hostConfig.hostname || hostId,
|
||||||
|
catalog_id: catalogId || "linux_server",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存巡检结果
|
||||||
|
try {
|
||||||
|
inspectionResultStore.addResult(readFn, writeFn, {
|
||||||
|
...result,
|
||||||
|
device_id: hostId,
|
||||||
|
online: result.status === INSPECTION_STATUS.SUCCESS,
|
||||||
|
duration_ms: duration,
|
||||||
|
error_message: result.error,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Inspection] 保存巡检结果失败:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 告警判断(成功/失败都要判断,特别是 online 指标)
|
||||||
|
const triggeredAlerts = [];
|
||||||
|
try {
|
||||||
|
const deviceExt = deviceStore.getExtension(readFn, hostId);
|
||||||
|
const rules = alertRuleStore.getRulesForDevice(readFn, deviceExt || { host_id: hostId, catalog_id: catalogId });
|
||||||
|
const isOnline = result.status === INSPECTION_STATUS.SUCCESS;
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
// online 指标 — 成功/失败都要判断(设备离线告警)
|
||||||
|
if (rule.metric === "online") {
|
||||||
|
const shouldAlert = rule.threshold === "false" ? !isOnline : isOnline;
|
||||||
|
console.log(`[Inspection] 离线规则: name=${rule.name} enabled=${rule.enabled} isOnline=${isOnline} shouldAlert=${shouldAlert}`);
|
||||||
|
const alertResult = alertRecordStore.processAlert(
|
||||||
|
readFn,
|
||||||
|
writeFn,
|
||||||
|
deviceInfo,
|
||||||
|
rule,
|
||||||
|
isOnline ? "在线" : "离线",
|
||||||
|
shouldAlert
|
||||||
|
);
|
||||||
|
if (alertResult) {
|
||||||
|
console.log(`[Inspection] 告警触发:`, JSON.stringify(alertResult));
|
||||||
|
triggeredAlerts.push(alertResult);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通指标 — 只在成功时判断(失败时没有指标数据)
|
||||||
|
if (isOnline) {
|
||||||
|
const metricValue = result.metrics[rule.metric];
|
||||||
|
if (metricValue === undefined || metricValue === null) continue;
|
||||||
|
|
||||||
|
const isTriggered = alertRuleStore.evaluateRule(metricValue, rule.operator, rule.threshold);
|
||||||
|
const alertResult = alertRecordStore.processAlert(
|
||||||
|
readFn,
|
||||||
|
writeFn,
|
||||||
|
deviceInfo,
|
||||||
|
rule,
|
||||||
|
metricValue,
|
||||||
|
isTriggered
|
||||||
|
);
|
||||||
|
if (alertResult) {
|
||||||
|
triggeredAlerts.push(alertResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Inspection] 告警判断失败:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.status === INSPECTION_STATUS.SUCCESS,
|
||||||
|
status: result.status,
|
||||||
|
metrics: result.metrics,
|
||||||
|
error: result.error,
|
||||||
|
duration,
|
||||||
|
alerts: triggeredAlerts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 告警记录 ----------
|
||||||
|
ipcMain.handle("inspection:listAlerts", (_e, options) => {
|
||||||
|
return alertRecordStore.listAlerts(readFn, options || {});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getActiveAlerts", () => {
|
||||||
|
return alertRecordStore.getActiveAlerts(readFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:getAlertStats", () => {
|
||||||
|
return alertRecordStore.getAlertStats(readFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:acknowledgeAlert", (_e, alertId, operator) => {
|
||||||
|
return alertRecordStore.acknowledgeAlert(readFn, writeFn, alertId, operator);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:resolveAlert", (_e, alertId, reason, operator) => {
|
||||||
|
return alertRecordStore.resolveAlert(readFn, writeFn, alertId, reason, operator);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:closeAlert", (_e, alertId, note) => {
|
||||||
|
return alertRecordStore.closeAlert(readFn, writeFn, alertId, note);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:updateAlertNote", (_e, alertId, note) => {
|
||||||
|
return alertRecordStore.updateNote(readFn, writeFn, alertId, note);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 存储前端传入的设备 SSH 配置(hostId → sshConfig)
|
||||||
|
let _hostConfigs = null;
|
||||||
|
|
||||||
|
// ---------- 调度器控制 ----------
|
||||||
|
ipcMain.handle("inspection:scheduler:start", (_e, options = {}) => {
|
||||||
|
// 前端可传入 { hostConfigs: { [hostId]: sshConfig } } 用于调度器执行 SSH 命令
|
||||||
|
if (options.hostConfigs) {
|
||||||
|
_hostConfigs = options.hostConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 hostConfigs 提取 hostInfo(hostname/label)用于告警显示
|
||||||
|
const hostInfo = {};
|
||||||
|
if (_hostConfigs) {
|
||||||
|
for (const [hostId, config] of Object.entries(_hostConfigs)) {
|
||||||
|
hostInfo[hostId] = {
|
||||||
|
hostname: config.host || config.hostname || "",
|
||||||
|
label: config.label || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_scheduler) {
|
||||||
|
_scheduler = new InspectionScheduler({
|
||||||
|
readFn,
|
||||||
|
writeFn,
|
||||||
|
execFn: async (hostId, command) => {
|
||||||
|
// 优先用前端传入的 hostConfigs
|
||||||
|
if (_hostConfigs && _hostConfigs[hostId]) {
|
||||||
|
const config = _hostConfigs[hostId];
|
||||||
|
const { execSshCommand: execCmd } = require("./inspectionExec.cjs");
|
||||||
|
return await execCmd(config, command, {
|
||||||
|
connectTimeoutMs: 10000,
|
||||||
|
execTimeoutMs: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 兜底:尝试复用已有终端会话
|
||||||
|
const session = findSessionByHost(hostId);
|
||||||
|
if (session?.conn) {
|
||||||
|
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||||
|
return await executeBoundedSshCommand(session.conn, command, {
|
||||||
|
timeoutMs: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`无法执行巡检:未找到主机 ${hostId} 的 SSH 配置`);
|
||||||
|
},
|
||||||
|
pollIntervalMs: 30 * 1000,
|
||||||
|
concurrency: 5,
|
||||||
|
timeoutMs: 30 * 1000,
|
||||||
|
hostInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_scheduler.start();
|
||||||
|
return _scheduler.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:scheduler:stop", () => {
|
||||||
|
if (_scheduler) {
|
||||||
|
_scheduler.stop();
|
||||||
|
return _scheduler.getStatus();
|
||||||
|
}
|
||||||
|
return { running: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:scheduler:status", () => {
|
||||||
|
return _scheduler ? _scheduler.getStatus() : { running: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:scheduler:scanNow", (_e, hostId) => {
|
||||||
|
if (!_scheduler) return { error: "调度器未启动" };
|
||||||
|
return _scheduler.scanDeviceNow(hostId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("inspection:scheduler:scanAllNow", () => {
|
||||||
|
if (!_scheduler) return { error: "调度器未启动" };
|
||||||
|
return _scheduler.scanAllNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Inspection] IPC handlers 已注册");
|
||||||
|
|
||||||
|
return {
|
||||||
|
getScheduler: () => _scheduler,
|
||||||
|
createScheduler: (opts) => {
|
||||||
|
_scheduler = new InspectionScheduler({
|
||||||
|
readFn,
|
||||||
|
writeFn,
|
||||||
|
execFn: execCommand,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
return _scheduler;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerInspectionHandlers,
|
||||||
|
getLabels,
|
||||||
|
};
|
||||||
417
electron/bridges/inspection/inspectionExec.cjs
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检执行器 - 用 ssh2 临时建立 SSH 连接执行命令
|
||||||
|
*
|
||||||
|
* 用于设备巡检的命令执行:每次巡检临时建立连接,执行完即释放,
|
||||||
|
* 不占用 terminalWorker 的连接池(巡检是低频后台任务)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { Client: SSHClient } = require("ssh2");
|
||||||
|
const { buildAlgorithms } = require("../sshAlgorithms.cjs");
|
||||||
|
|
||||||
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
||||||
|
const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
|
||||||
|
const DEFAULT_MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单条 SSH 命令(使用 pty shell 方式,兼容交换机等需要交互式 shell 的设备)
|
||||||
|
* @param {object} connConfig - ssh2 连接配置(host/port/username/password/privateKey 等)
|
||||||
|
* @param {string} command - 要执行的命令
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {number} [options.connectTimeoutMs]
|
||||||
|
* @param {number} [options.execTimeoutMs]
|
||||||
|
* @param {number} [options.maxOutputBytes]
|
||||||
|
* @returns {Promise<string>} 命令输出
|
||||||
|
*/
|
||||||
|
function execSshCommand(connConfig, command, options = {}) {
|
||||||
|
const connectTimeoutMs = options.connectTimeoutMs || DEFAULT_CONNECT_TIMEOUT_MS;
|
||||||
|
const execTimeoutMs = options.execTimeoutMs || DEFAULT_EXEC_TIMEOUT_MS;
|
||||||
|
const maxOutputBytes = options.maxOutputBytes || DEFAULT_MAX_OUTPUT_BYTES;
|
||||||
|
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[InspectExec] SSH host:", connConfig.host, "| cmd:", command.slice(0, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = new SSHClient();
|
||||||
|
let settled = false;
|
||||||
|
let stdout = "";
|
||||||
|
let execTimer = null;
|
||||||
|
let connectTimer = null;
|
||||||
|
let commandSent = false;
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
client.on("banner", (msg) => console.log("[InspectExec] banner:", msg.slice(0, 100)));
|
||||||
|
client.on("handshake", () => console.log("[InspectExec] handshake OK"));
|
||||||
|
client.on("error", (err) => console.error("[InspectExec] client error:", err.message));
|
||||||
|
client.on("close", () => console.log("[InspectExec] client closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (execTimer) clearTimeout(execTimer);
|
||||||
|
if (connectTimer) clearTimeout(connectTimer);
|
||||||
|
try { client.end(); } catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = (err, result) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接超时
|
||||||
|
connectTimer = setTimeout(() => {
|
||||||
|
console.error("[InspectExec] 连接超时,host:", connConfig.host);
|
||||||
|
done(new Error("SSH 连接超时(" + connectTimeoutMs / 1000 + "s)"));
|
||||||
|
}, connectTimeoutMs);
|
||||||
|
|
||||||
|
client.on("ready", () => {
|
||||||
|
if (DEBUG) console.log("[InspectExec] SSH ready,打开 pty shell...");
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
|
||||||
|
// 使用 pty shell(交互式)执行命令
|
||||||
|
client.shell({ term: "vt100", cols: 80, rows: 24 }, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("[InspectExec] shell 失败:", err.message);
|
||||||
|
done(new Error("打开 shell 失败: " + err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行超时
|
||||||
|
execTimer = setTimeout(() => {
|
||||||
|
console.error("[InspectExec] 命令执行超时(" + execTimeoutMs / 1000 + "s)");
|
||||||
|
done(new Error("命令执行超时"));
|
||||||
|
}, execTimeoutMs);
|
||||||
|
|
||||||
|
let outputBuffer = "";
|
||||||
|
|
||||||
|
let lastLogTime = 0;
|
||||||
|
stream.on("data", (data) => {
|
||||||
|
const chunk = data.toString("utf-8");
|
||||||
|
outputBuffer += chunk;
|
||||||
|
|
||||||
|
// 检测是否出现命令提示符
|
||||||
|
if (!commandSent) {
|
||||||
|
// 交换机提示符格式: <hostname> 或 hostname>
|
||||||
|
if (chunk.includes(">") || chunk.includes("<")) {
|
||||||
|
commandSent = true;
|
||||||
|
stream.write(command + "\n");
|
||||||
|
if (DEBUG) console.log("[InspectExec] 已发送命令:", command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令发送后,收集输出
|
||||||
|
if (commandSent) {
|
||||||
|
// 检测分页提示,发送 'q' 退出
|
||||||
|
if (outputBuffer.includes("-- More --") || outputBuffer.includes("---- More ----")) {
|
||||||
|
if (!outputBuffer.includes("__sent_q__")) {
|
||||||
|
stream.write("q");
|
||||||
|
outputBuffer += "__sent_q__"; // 避免重复发送
|
||||||
|
if (DEBUG) console.log("[InspectExec] 检测到分页,发送 q");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测输出完成:出现新提示符
|
||||||
|
const lines = outputBuffer.split("\n");
|
||||||
|
if (lines.length > 5) {
|
||||||
|
// 检查倒数第二行是否是提示符(因为最后一行为空)
|
||||||
|
const lastContentLine = lines[lines.length - 2]?.trim() || "";
|
||||||
|
if (lastContentLine.includes(">") || lastContentLine.includes("#")) {
|
||||||
|
stdout = outputBuffer.replace(/__sent_q__/g, "");
|
||||||
|
clearTimeout(execTimer);
|
||||||
|
if (DEBUG) console.log("[InspectExec] 命令完成,行数:", lines.length);
|
||||||
|
done(null, stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止缓冲区过大
|
||||||
|
if (outputBuffer.length > maxOutputBytes * 2) {
|
||||||
|
stdout = outputBuffer.replace(/__sent_q__/g, "");
|
||||||
|
clearTimeout(execTimer);
|
||||||
|
if (DEBUG) console.log("[InspectExec] 缓冲区满,强制返回");
|
||||||
|
done(null, stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (data) => {
|
||||||
|
// stderr 通常为空
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", () => {
|
||||||
|
clearTimeout(execTimer);
|
||||||
|
if (!commandSent) {
|
||||||
|
// 没等到提示符就关闭了,再试一次发送命令
|
||||||
|
commandSent = true;
|
||||||
|
stream.write(command + "\n");
|
||||||
|
} else if (stdout) {
|
||||||
|
// 已有输出,正常结束
|
||||||
|
done(null, stdout);
|
||||||
|
} else {
|
||||||
|
done(new Error("连接意外关闭"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (e) => {
|
||||||
|
console.error("[InspectExec] stream error:", e.message);
|
||||||
|
done(new Error("流错误: " + e.message));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("error", (err) => {
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
console.error("[InspectExec] SSH 连接失败:", err.message);
|
||||||
|
done(new Error("SSH 连接失败: " + err.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.connect({
|
||||||
|
host: connConfig.host,
|
||||||
|
port: connConfig.port || 22,
|
||||||
|
username: connConfig.username,
|
||||||
|
password: connConfig.password || undefined,
|
||||||
|
privateKey: connConfig.privateKey || undefined,
|
||||||
|
passphrase: connConfig.passphrase || undefined,
|
||||||
|
readyTimeout: connectTimeoutMs,
|
||||||
|
// 跳过主机密钥验证
|
||||||
|
hostVerifier: () => true,
|
||||||
|
// 使用 NetMesh 的算法构建器,启用旧算法兼容,跳过 ECDSA 主机密钥
|
||||||
|
algorithms: buildAlgorithms(true, { skipEcdsaHostKey: true }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
done(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量执行多条 SSH 命令(一次连接,执行多条命令)
|
||||||
|
* @param {object} connConfig - ssh2 连接配置
|
||||||
|
* @param {string[]} commands - 命令数组
|
||||||
|
* @param {object} [options]
|
||||||
|
* @returns {Promise<object>} { outputs: { command, stdout }[] }
|
||||||
|
*/
|
||||||
|
function execSshCommands(connConfig, commands, options = {}) {
|
||||||
|
const connectTimeoutMs = options.connectTimeoutMs || DEFAULT_CONNECT_TIMEOUT_MS;
|
||||||
|
const totalTimeoutMs = options.totalTimeoutMs || 180000; // 总超时 3 分钟
|
||||||
|
const maxOutputBytes = options.maxOutputBytes || DEFAULT_MAX_OUTPUT_BYTES;
|
||||||
|
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[InspectExec] 批量执行:", connConfig.host, "| 命令数:", commands.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = new SSHClient();
|
||||||
|
let settled = false;
|
||||||
|
let connectTimer = null;
|
||||||
|
let totalTimer = null;
|
||||||
|
let cmdTimeout = null;
|
||||||
|
let outputBuffer = "";
|
||||||
|
let commandIndex = 0;
|
||||||
|
let phase = "wait-login"; // wait-login → disable-paging → idle → waiting-output → done
|
||||||
|
let disableRetries = 0;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (connectTimer) clearTimeout(connectTimer);
|
||||||
|
if (totalTimer) clearTimeout(totalTimer);
|
||||||
|
if (cmdTimeout) clearTimeout(cmdTimeout);
|
||||||
|
try { client.end(); } catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = (err, result) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 总超时
|
||||||
|
totalTimer = setTimeout(() => {
|
||||||
|
done(new Error("批量命令执行超时(" + totalTimeoutMs / 1000 + "s)"));
|
||||||
|
}, totalTimeoutMs);
|
||||||
|
|
||||||
|
// 匹配网络设备提示符: 取最后一行,检查是 <hostname> 或 hostname>/hostname# 格式
|
||||||
|
function hasPrompt(text) {
|
||||||
|
const lines = (text || '').split('\n');
|
||||||
|
// 从后往前找非空行
|
||||||
|
let lastLine = '';
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
const trimmed = lines[i].trimEnd();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
lastLine = trimmed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[InspectExec] hasPrompt check:", JSON.stringify(lastLine.slice(-100)));
|
||||||
|
}
|
||||||
|
// <hostname> 格式(H3C/华为 VRP)
|
||||||
|
if (/<[\w\-./]+>\s*$/.test(lastLine)) return true;
|
||||||
|
// hostname> 或 hostname# 格式
|
||||||
|
if (/[\w\-./]+[>#]\s*$/.test(lastLine)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.on("ready", () => {
|
||||||
|
if (DEBUG) console.log("[InspectExec] SSH ready,打开 shell...");
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
|
||||||
|
client.shell({ term: "vt100", cols: 200, rows: 24 }, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
done(new Error("打开 shell 失败: " + err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase = "wait-login"; // wait-login → disable-paging → idle → waiting-output → done
|
||||||
|
let disableRetries = 0;
|
||||||
|
|
||||||
|
stream.on("data", (data) => {
|
||||||
|
const chunk = data.toString("utf-8");
|
||||||
|
outputBuffer += chunk;
|
||||||
|
|
||||||
|
if (DEBUG && outputBuffer.length % 1000 < 100) {
|
||||||
|
// 每隔约 1KB 打印一次状态(避免刷屏)
|
||||||
|
const lines = outputBuffer.split('\n');
|
||||||
|
console.log(`[InspectExec] phase=${phase} bufLen=${outputBuffer.length} lastLines=${JSON.stringify(lines.slice(-3))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "wait-login") {
|
||||||
|
if (hasPrompt(outputBuffer)) {
|
||||||
|
phase = "disable-paging";
|
||||||
|
outputBuffer = "";
|
||||||
|
if (DEBUG) console.log("[InspectExec] 登录成功,发送 screen-length 0 关闭分页");
|
||||||
|
stream.write("screen-length 0 temporary\n");
|
||||||
|
}
|
||||||
|
} else if (phase === "disable-paging") {
|
||||||
|
if (hasPrompt(outputBuffer)) {
|
||||||
|
phase = "idle";
|
||||||
|
outputBuffer = "";
|
||||||
|
if (DEBUG) console.log("[InspectExec] 分页已关闭");
|
||||||
|
sendNextCommand();
|
||||||
|
} else if (outputBuffer.length > 500 && disableRetries < 1) {
|
||||||
|
disableRetries++;
|
||||||
|
outputBuffer = "";
|
||||||
|
stream.write("screen-length 0 temporary\n");
|
||||||
|
if (DEBUG) console.log("[InspectExec] 重试关闭分页");
|
||||||
|
}
|
||||||
|
} else if (phase === "waiting-output") {
|
||||||
|
// 处理分页提示符:H3C/华为交换机可能在输出中途显示 "-- More --"
|
||||||
|
// 注意:如果 switch 不认识 screen-length 0,分页仍然会发生
|
||||||
|
const moreIdx = outputBuffer.indexOf("-- More --");
|
||||||
|
if (moreIdx !== -1 || outputBuffer.indexOf("---- More ----") !== -1) {
|
||||||
|
if (DEBUG) console.log("[InspectExec] 检测到分页,发送空格继续");
|
||||||
|
stream.write(" ");
|
||||||
|
// 把分页提示符从缓冲区移除,避免干扰
|
||||||
|
outputBuffer = outputBuffer.replace(/--\s*More\s*--/gi, "").replace(/----\s*More\s*----/gi, "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasPrompt(outputBuffer)) {
|
||||||
|
// 命令输出完整,保存
|
||||||
|
const cmdOutput = cleanOutput(outputBuffer);
|
||||||
|
results[results.length - 1].output = cmdOutput;
|
||||||
|
outputBuffer = "";
|
||||||
|
phase = "idle";
|
||||||
|
if (DEBUG) console.log("[InspectExec] 命令输出完成,进入 idle");
|
||||||
|
sendNextCommand();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", () => {
|
||||||
|
if (phase === "done") {
|
||||||
|
// 主动 quit 关闭的,正常 resolve
|
||||||
|
if (!settled) done(null, { results });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!settled) {
|
||||||
|
done(new Error("连接意外关闭"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("error", (e) => {
|
||||||
|
done(new Error("流错误: " + e.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendNextCommand() {
|
||||||
|
if (commandIndex >= commands.length) {
|
||||||
|
if (DEBUG) console.log("[InspectExec] 所有命令执行完毕");
|
||||||
|
phase = "done";
|
||||||
|
stream.write("quit\n");
|
||||||
|
setTimeout(() => done(null, { results }), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = commands[commandIndex];
|
||||||
|
if (DEBUG) console.log("[InspectExec] 发送命令 " + (commandIndex + 1) + "/" + commands.length + ":", cmd.slice(0, 50));
|
||||||
|
|
||||||
|
stream.write(cmd + "\n");
|
||||||
|
results.push({ command: cmd, output: "" });
|
||||||
|
commandIndex++;
|
||||||
|
phase = "waiting-output";
|
||||||
|
|
||||||
|
// 每个命令 15 秒超时,防止卡住
|
||||||
|
if (cmdTimeout) clearTimeout(cmdTimeout);
|
||||||
|
cmdTimeout = setTimeout(() => {
|
||||||
|
if (phase === "waiting-output") {
|
||||||
|
if (DEBUG) console.log("[InspectExec] 命令超时,强制完成");
|
||||||
|
const cmdOutput = cleanOutput(outputBuffer);
|
||||||
|
results[results.length - 1].output = cmdOutput;
|
||||||
|
outputBuffer = "";
|
||||||
|
phase = "idle";
|
||||||
|
sendNextCommand();
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanOutput(text) {
|
||||||
|
return (text || "")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/--\s*More\s*--/gi, "")
|
||||||
|
.replace(/----\s*More\s*----/gi, "")
|
||||||
|
.replace(/\r/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("error", (err) => {
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
done(new Error("SSH 连接失败: " + err.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.connect({
|
||||||
|
host: connConfig.host,
|
||||||
|
port: connConfig.port || 22,
|
||||||
|
username: connConfig.username,
|
||||||
|
password: connConfig.password || undefined,
|
||||||
|
privateKey: connConfig.privateKey || undefined,
|
||||||
|
passphrase: connConfig.passphrase || undefined,
|
||||||
|
readyTimeout: connectTimeoutMs,
|
||||||
|
hostVerifier: () => true,
|
||||||
|
algorithms: buildAlgorithms(true, { skipEcdsaHostKey: true }),
|
||||||
|
// 保活配置,防止交换机 VTY 超时断开
|
||||||
|
keepaliveInterval: 30000, // 每 30 秒发送一次心跳
|
||||||
|
keepaliveCountMax: 3, // 最多重试 3 次
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
done(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
execSshCommand,
|
||||||
|
execSshCommands,
|
||||||
|
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||||
|
DEFAULT_EXEC_TIMEOUT_MS,
|
||||||
|
};
|
||||||
126
electron/bridges/inspection/inspectionResultStore.cjs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检结果存储
|
||||||
|
*
|
||||||
|
* 保存每次巡检的结果数据,支持按设备查询历史。
|
||||||
|
* 为避免数据量过大,仅保留最近 N 条记录(默认 1000 条)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { STORAGE_KEY_INSPECTION_RESULTS } = require("./storageKeys.cjs");
|
||||||
|
const { INSPECTION_STATUS } = require("./types.cjs");
|
||||||
|
|
||||||
|
const MAX_RESULTS = 1000;
|
||||||
|
|
||||||
|
let _results = null;
|
||||||
|
|
||||||
|
function _ensureLoaded(readFn) {
|
||||||
|
if (_results === null) {
|
||||||
|
const raw = readFn(STORAGE_KEY_INSPECTION_RESULTS);
|
||||||
|
_results = raw ? _safeParse(raw, []) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _safeParse(text, def) {
|
||||||
|
try { return JSON.parse(text); }
|
||||||
|
catch (e) { return def; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _save(writeFn) {
|
||||||
|
if (_results.length > MAX_RESULTS) {
|
||||||
|
_results = _results.slice(-MAX_RESULTS);
|
||||||
|
}
|
||||||
|
writeFn(STORAGE_KEY_INSPECTION_RESULTS, JSON.stringify(_results));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 增删改查 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增一条巡检结果
|
||||||
|
*/
|
||||||
|
function addResult(readFn, writeFn, result) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
id: `result_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
job_id: result.job_id || null,
|
||||||
|
device_id: result.device_id, // host_id
|
||||||
|
timestamp: Date.now(),
|
||||||
|
online: result.online,
|
||||||
|
status: result.status || INSPECTION_STATUS.SUCCESS,
|
||||||
|
duration_ms: result.duration_ms || 0,
|
||||||
|
metrics: result.metrics || {},
|
||||||
|
alerts: result.alerts || [],
|
||||||
|
error_message: result.error_message || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
_results.push(record);
|
||||||
|
_save(writeFn);
|
||||||
|
return { ...record };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定设备的最近巡检结果
|
||||||
|
*/
|
||||||
|
function getLatestByDevice(readFn, hostId, limit = 20) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return _results
|
||||||
|
.filter((r) => r.device_id === hostId)
|
||||||
|
.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有设备的最新一次巡检结果
|
||||||
|
*/
|
||||||
|
function getLatestAll(readFn) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
const latest = {};
|
||||||
|
for (const r of _results) {
|
||||||
|
if (!latest[r.device_id] || r.timestamp > latest[r.device_id].timestamp) {
|
||||||
|
latest[r.device_id] = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.values(latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近 N 条结果
|
||||||
|
*/
|
||||||
|
function getRecent(readFn, limit = 50) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return [..._results].sort((a, b) => b.timestamp - a.timestamp).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据时间范围查询
|
||||||
|
*/
|
||||||
|
function getByTimeRange(readFn, startTime, endTime) {
|
||||||
|
_ensureLoaded(readFn);
|
||||||
|
return _results.filter(
|
||||||
|
(r) => r.timestamp >= startTime && r.timestamp <= endTime
|
||||||
|
).sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空结果(手动清理)
|
||||||
|
*/
|
||||||
|
function clearResults(readFn, writeFn) {
|
||||||
|
_results = [];
|
||||||
|
_save(writeFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 重置(测试用) ----------
|
||||||
|
function _resetForTest() {
|
||||||
|
_results = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
addResult,
|
||||||
|
getLatestByDevice,
|
||||||
|
getLatestAll,
|
||||||
|
getRecent,
|
||||||
|
getByTimeRange,
|
||||||
|
clearResults,
|
||||||
|
_resetForTest,
|
||||||
|
};
|
||||||
383
electron/bridges/inspection/inspectionScheduler.cjs
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检调度引擎
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 定时扫描启用了巡检的设备
|
||||||
|
* 2. 根据各设备的巡检周期,判断是否需要执行
|
||||||
|
* 3. 并发执行多台设备的巡检
|
||||||
|
* 4. 采集指标 → 判断告警 → 写入结果和告警记录
|
||||||
|
*
|
||||||
|
* 设计说明:
|
||||||
|
* - 每 30 秒轮询一次(检查哪些设备到巡检时间了)
|
||||||
|
* - 并发数默认 5,可配置
|
||||||
|
* - 单设备超时默认 30 秒
|
||||||
|
* - 巡检结果通过回调通知上层(更新 UI 用)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const deviceStore = require("./deviceStore.cjs");
|
||||||
|
const alertRuleStore = require("./alertRuleStore.cjs");
|
||||||
|
const inspectionResultStore = require("./inspectionResultStore.cjs");
|
||||||
|
const alertRecordStore = require("./alertRecordStore.cjs");
|
||||||
|
const adapterRegistry = require("./adapterRegistry.cjs");
|
||||||
|
const { INSPECTION_STATUS } = require("./types.cjs");
|
||||||
|
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 30 * 1000; // 30 秒轮询一次
|
||||||
|
const DEFAULT_CONCURRENCY = 5;
|
||||||
|
const DEFAULT_DEVICE_TIMEOUT_MS = 30 * 1000;
|
||||||
|
|
||||||
|
class InspectionScheduler {
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {function} options.readFn - 读取存储 (key) => string
|
||||||
|
* @param {function} options.writeFn - 写入存储 (key, value) => void
|
||||||
|
* @param {function} options.getConnectionFn - 获取 SSH 连接 (hostId) => connection 或 null
|
||||||
|
* @param {function} options.execFn - 执行命令 (hostId, command) => Promise<string>
|
||||||
|
* @param {function} [options.onResult] - 单设备巡检完成回调
|
||||||
|
* @param {number} [options.pollIntervalMs] - 轮询间隔
|
||||||
|
* @param {number} [options.concurrency] - 并发数
|
||||||
|
* @param {number} [options.timeoutMs] - 单设备超时
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.readFn = options.readFn;
|
||||||
|
this.writeFn = options.writeFn;
|
||||||
|
this.getConnectionFn = options.getConnectionFn;
|
||||||
|
this.execFn = options.execFn;
|
||||||
|
this.onResult = options.onResult || (() => {});
|
||||||
|
this.pollIntervalMs = options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
this.concurrency = options.concurrency || DEFAULT_CONCURRENCY;
|
||||||
|
this.timeoutMs = options.timeoutMs || DEFAULT_DEVICE_TIMEOUT_MS;
|
||||||
|
// hostId → { hostname, label } 映射,用于告警记录显示设备名称
|
||||||
|
this._hostInfo = options.hostInfo || {};
|
||||||
|
|
||||||
|
this._timer = null;
|
||||||
|
this._running = false;
|
||||||
|
this._scanQueue = []; // 待巡检的设备队列
|
||||||
|
this._activeScans = 0; // 当前正在执行的巡检数
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 生命周期 ----------
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this._running) return;
|
||||||
|
this._running = true;
|
||||||
|
console.log("[InspectionScheduler] 巡检调度器已启动");
|
||||||
|
|
||||||
|
// 启动时立即跑一轮(异步,不阻塞 start 返回)
|
||||||
|
setImmediate(() => this._tick());
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this._running = false;
|
||||||
|
if (this._timer) {
|
||||||
|
clearTimeout(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
console.log("[InspectionScheduler] 巡检调度器已停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning() {
|
||||||
|
return this._running;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleNextTick() {
|
||||||
|
if (!this._running) return;
|
||||||
|
this._timer = setTimeout(() => this._tick(), this.pollIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 主循环 ----------
|
||||||
|
|
||||||
|
async _tick() {
|
||||||
|
if (!this._running) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取所有启用巡检的设备
|
||||||
|
const devices = deviceStore.getEnabledDevices(this.readFn);
|
||||||
|
|
||||||
|
// 2. 筛选出到期需要巡检的设备
|
||||||
|
const now = Date.now();
|
||||||
|
const dueDevices = devices.filter((dev) => {
|
||||||
|
const intervalMs = dev.effective_interval * 60 * 1000;
|
||||||
|
const lastScan = dev.last_scan_at || 0;
|
||||||
|
return now - lastScan >= intervalMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dueDevices.length > 0) {
|
||||||
|
console.log(`[InspectionScheduler] 到期设备 ${dueDevices.length} 台,加入巡检队列`);
|
||||||
|
// 加入队列
|
||||||
|
for (const dev of dueDevices) {
|
||||||
|
if (!this._scanQueue.find((d) => d.host_id === dev.host_id)) {
|
||||||
|
this._scanQueue.push(dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 启动并发执行
|
||||||
|
this._processQueue();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[InspectionScheduler] 调度异常:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 安排下一次轮询
|
||||||
|
this._scheduleNextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
_processQueue() {
|
||||||
|
while (this._activeScans < this.concurrency && this._scanQueue.length > 0) {
|
||||||
|
const device = this._scanQueue.shift();
|
||||||
|
this._activeScans++;
|
||||||
|
this._scanDevice(device).finally(() => {
|
||||||
|
this._activeScans--;
|
||||||
|
// 继续处理队列
|
||||||
|
this._processQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 单设备巡检 ----------
|
||||||
|
|
||||||
|
async _scanDevice(deviceExt) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const hostId = deviceExt.host_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取设备类型信息
|
||||||
|
const catalog = deviceStore.getCatalogById(deviceExt.catalog_id);
|
||||||
|
if (!catalog) {
|
||||||
|
throw new Error(`未找到设备类型: ${deviceExt.catalog_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 创建采集器
|
||||||
|
const adapter = adapterRegistry.createAdapter(
|
||||||
|
catalog.adapter_class,
|
||||||
|
{ host: hostId },
|
||||||
|
{
|
||||||
|
execFn: async (cmd) => {
|
||||||
|
if (!this.execFn) throw new Error("execFn 未配置");
|
||||||
|
return this.execFn(hostId, cmd);
|
||||||
|
},
|
||||||
|
timeoutMs: this.timeoutMs,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!adapter) {
|
||||||
|
throw new Error(`采集器未注册: ${catalog.adapter_class}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 执行采集(带超时)
|
||||||
|
const result = await this._withTimeout(
|
||||||
|
adapter.collect(),
|
||||||
|
this.timeoutMs,
|
||||||
|
"巡检超时"
|
||||||
|
);
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 4. 保存巡检结果
|
||||||
|
const record = inspectionResultStore.addResult(
|
||||||
|
this.readFn,
|
||||||
|
this.writeFn,
|
||||||
|
{
|
||||||
|
device_id: hostId,
|
||||||
|
online: result.online,
|
||||||
|
status: result.status,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
metrics: result.metrics,
|
||||||
|
alerts: [],
|
||||||
|
error_message: result.error,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. 更新设备最后巡检状态
|
||||||
|
deviceStore.updateLastScan(
|
||||||
|
this.readFn,
|
||||||
|
this.writeFn,
|
||||||
|
hostId,
|
||||||
|
result.status,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 判断告警
|
||||||
|
const triggeredAlerts = this._evaluateAlerts(deviceExt, catalog, result.metrics);
|
||||||
|
record.alerts = triggeredAlerts;
|
||||||
|
|
||||||
|
// 7. 回调通知
|
||||||
|
this.onResult({
|
||||||
|
device_id: hostId,
|
||||||
|
result: record,
|
||||||
|
alerts: triggeredAlerts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
} catch (e) {
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 失败也要记录
|
||||||
|
const record = inspectionResultStore.addResult(
|
||||||
|
this.readFn,
|
||||||
|
this.writeFn,
|
||||||
|
{
|
||||||
|
device_id: hostId,
|
||||||
|
online: false,
|
||||||
|
status: e.message.includes("超时")
|
||||||
|
? INSPECTION_STATUS.TIMEOUT
|
||||||
|
: INSPECTION_STATUS.FAILED,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
metrics: {},
|
||||||
|
alerts: [],
|
||||||
|
error_message: e.message,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
deviceStore.updateLastScan(
|
||||||
|
this.readFn,
|
||||||
|
this.writeFn,
|
||||||
|
hostId,
|
||||||
|
e.message.includes("超时") ? INSPECTION_STATUS.TIMEOUT : INSPECTION_STATUS.FAILED,
|
||||||
|
e.message
|
||||||
|
);
|
||||||
|
|
||||||
|
// 离线告警(设备离线也要触发告警)
|
||||||
|
const triggeredAlerts = this._evaluateAlerts(deviceExt, null, { online: false });
|
||||||
|
record.alerts = triggeredAlerts;
|
||||||
|
|
||||||
|
this.onResult({
|
||||||
|
device_id: hostId,
|
||||||
|
result: record,
|
||||||
|
alerts: triggeredAlerts,
|
||||||
|
error: e.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 告警判断 ----------
|
||||||
|
|
||||||
|
_evaluateAlerts(deviceExt, catalog, metrics) {
|
||||||
|
const triggered = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取该设备适用的规则
|
||||||
|
const rules = alertRuleStore.getRulesForDevice(this.readFn, deviceExt);
|
||||||
|
const hostInfo = this._hostInfo[deviceExt.host_id] || {};
|
||||||
|
const deviceName = hostInfo.label || hostInfo.hostname || deviceExt.host_id;
|
||||||
|
console.log(`[InspectionScheduler] 告警评估 device=${deviceName}(${deviceExt.host_id}) online=${metrics.online} 规则数=${rules.length}`);
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
// 特殊处理:online 指标
|
||||||
|
if (rule.metric === "online") {
|
||||||
|
const isOnline = metrics.online !== false;
|
||||||
|
// threshold 为 "false" 表示离线时告警
|
||||||
|
const shouldAlert = rule.threshold === "false" ? !isOnline : isOnline;
|
||||||
|
console.log(`[InspectionScheduler] 离线规则: name=${rule.name} enabled=${rule.enabled} threshold=${rule.threshold} isOnline=${isOnline} shouldAlert=${shouldAlert}`);
|
||||||
|
const alertResult = alertRecordStore.processAlert(
|
||||||
|
this.readFn,
|
||||||
|
this.writeFn,
|
||||||
|
{ device_id: deviceExt.host_id, device_name: deviceName },
|
||||||
|
rule,
|
||||||
|
isOnline ? "在线" : "离线",
|
||||||
|
shouldAlert
|
||||||
|
);
|
||||||
|
if (alertResult) {
|
||||||
|
console.log(`[InspectionScheduler] 告警触发:`, JSON.stringify(alertResult));
|
||||||
|
triggered.push(alertResult);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通数值指标
|
||||||
|
const value = this._getMetricValue(metrics, rule.metric);
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
|
||||||
|
const isTriggered = alertRuleStore.evaluateRule(value, rule.operator, rule.threshold);
|
||||||
|
|
||||||
|
const hostInfo = this._hostInfo[deviceExt.host_id] || {};
|
||||||
|
const deviceName = hostInfo.label || hostInfo.hostname || deviceExt.host_id;
|
||||||
|
const alertResult = alertRecordStore.processAlert(
|
||||||
|
this.readFn,
|
||||||
|
this.writeFn,
|
||||||
|
{ device_id: deviceExt.host_id, device_name: deviceName },
|
||||||
|
rule,
|
||||||
|
value,
|
||||||
|
isTriggered
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alertResult) triggered.push(alertResult);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[InspectionScheduler] 告警判断异常:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return triggered;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getMetricValue(metrics, metricKey) {
|
||||||
|
if (!metrics) return null;
|
||||||
|
// 直接取
|
||||||
|
if (metrics[metricKey] !== undefined) return metrics[metricKey];
|
||||||
|
// 磁盘最高使用率
|
||||||
|
if (metricKey === "disk_usage" && Array.isArray(metrics.disk_usage)) {
|
||||||
|
return Math.max(...metrics.disk_usage.map((d) => d.usage_percent || 0));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 手动巡检 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发单设备巡检
|
||||||
|
*/
|
||||||
|
async scanDeviceNow(hostId) {
|
||||||
|
const ext = deviceStore.getExtension(this.readFn, hostId);
|
||||||
|
if (!ext) throw new Error(`设备未配置巡检: ${hostId}`);
|
||||||
|
|
||||||
|
const interval = deviceStore.getEffectiveInterval(this.readFn, hostId);
|
||||||
|
const device = { ...ext, effective_interval: interval };
|
||||||
|
|
||||||
|
// 加入队列头部
|
||||||
|
this._scanQueue.unshift(device);
|
||||||
|
this._processQueue();
|
||||||
|
|
||||||
|
return { queued: true, host_id: hostId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发所有启用巡检的设备
|
||||||
|
*/
|
||||||
|
async scanAllNow() {
|
||||||
|
const devices = deviceStore.getEnabledDevices(this.readFn);
|
||||||
|
let count = 0;
|
||||||
|
for (const dev of devices) {
|
||||||
|
if (!this._scanQueue.find((d) => d.host_id === dev.host_id)) {
|
||||||
|
this._scanQueue.push(dev);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._processQueue();
|
||||||
|
return { queued: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 工具 ----------
|
||||||
|
|
||||||
|
_withTimeout(promise, ms, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(message || "超时")), ms);
|
||||||
|
promise.then(
|
||||||
|
(v) => { clearTimeout(timer); resolve(v); },
|
||||||
|
(e) => { clearTimeout(timer); reject(e); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
running: this._running,
|
||||||
|
queue_size: this._scanQueue.length,
|
||||||
|
active_scans: this._activeScans,
|
||||||
|
concurrency: this.concurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = InspectionScheduler;
|
||||||
89
electron/bridges/inspection/inspectionStorage.cjs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检模块主进程存储适配器
|
||||||
|
*
|
||||||
|
* 使用 userData 目录下的 JSON 文件存储巡检相关数据(设备配置、告警规则、巡检结果等)。
|
||||||
|
* 提供与 localStorage 类似的 key-value 接口,方便注入到 store 模块。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const STORAGE_FILENAME = "inspection-data.json";
|
||||||
|
let _storageCache = null;
|
||||||
|
let _filePath = null;
|
||||||
|
let _dirty = false;
|
||||||
|
let _saveTimer = null;
|
||||||
|
|
||||||
|
function init(userDataDir) {
|
||||||
|
_filePath = path.join(userDataDir, STORAGE_FILENAME);
|
||||||
|
_loadFromDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadFromDisk() {
|
||||||
|
if (!_filePath) {
|
||||||
|
_storageCache = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(_filePath)) {
|
||||||
|
const raw = fs.readFileSync(_filePath, "utf-8");
|
||||||
|
_storageCache = JSON.parse(raw);
|
||||||
|
} else {
|
||||||
|
_storageCache = {};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Inspection Storage] 加载失败:", e?.message);
|
||||||
|
_storageCache = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scheduleSave() {
|
||||||
|
if (_saveTimer) return;
|
||||||
|
_saveTimer = setTimeout(() => {
|
||||||
|
_saveTimer = null;
|
||||||
|
_flushToDisk();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _flushToDisk() {
|
||||||
|
if (!_filePath || !_dirty) return;
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(_filePath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(_filePath, JSON.stringify(_storageCache, null, 2), "utf-8");
|
||||||
|
_dirty = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Inspection Storage] 保存失败:", e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStorage(key) {
|
||||||
|
if (!_storageCache) _loadFromDisk();
|
||||||
|
return _storageCache[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStorage(key, value) {
|
||||||
|
if (!_storageCache) _loadFromDisk();
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
delete _storageCache[key];
|
||||||
|
} else {
|
||||||
|
_storageCache[key] = value;
|
||||||
|
}
|
||||||
|
_dirty = true;
|
||||||
|
_scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushSync() {
|
||||||
|
_flushToDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
readStorage,
|
||||||
|
writeStorage,
|
||||||
|
flushSync,
|
||||||
|
};
|
||||||
173
electron/bridges/inspection/promptParser.cjs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt 解析器 - 从命令输出中提取指标
|
||||||
|
*
|
||||||
|
* 分层策略:
|
||||||
|
* 1. 正则匹配(快,简单场景够用)
|
||||||
|
* 2. 返回原始输出(解析失败时让用户看原始数据)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 华为/H3C 交换机命令解析配置
|
||||||
|
*/
|
||||||
|
const HUAWEI_H3C_PATTERNS = {
|
||||||
|
cpu_usage: {
|
||||||
|
// CPU 使用率:多种可能的格式
|
||||||
|
patterns: [
|
||||||
|
/CPU\s+Usage\s*:\s*(\d+)%/i,
|
||||||
|
/CPU\s+utilization.*one\s+minute:\s*(\d+)%/i,
|
||||||
|
/CPU\s+Usage\s+:\s*(\d+)%/i,
|
||||||
|
],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
memory_usage: {
|
||||||
|
// 内存使用率:多种格式
|
||||||
|
patterns: [
|
||||||
|
/Memory\s+Using\s+Percentage\s+Is:\s*(\d+)%/i,
|
||||||
|
/Memory\s+utilization.*:\s*(\d+)%/i,
|
||||||
|
],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析单行指标
|
||||||
|
* @param {string} output - 命令原始输出
|
||||||
|
* @param {object} config - { patterns: RegExp[], default: any }
|
||||||
|
* @returns {any} 解析结果
|
||||||
|
*/
|
||||||
|
function parseMetric(output, config) {
|
||||||
|
if (!output || !config?.patterns) return config?.default ?? null;
|
||||||
|
|
||||||
|
for (const pattern of config.patterns) {
|
||||||
|
const match = output.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
return parseFloat(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config.default ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析端口状态
|
||||||
|
* @param {string} output - 命令输出
|
||||||
|
* @returns {object} { up_count, down_count, ports }
|
||||||
|
*/
|
||||||
|
function parseInterfaceBrief(output) {
|
||||||
|
const lines = output.split("\n");
|
||||||
|
const ports = [];
|
||||||
|
let upCount = 0;
|
||||||
|
let downCount = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 匹配端口行:GigabitEthernet0/0/1 up up
|
||||||
|
const portMatch = line.match(/^(GigabitEthernet|FastEthernet|Ethernet)(\S+)\s+(\w+)\s+(\w+)/);
|
||||||
|
if (portMatch) {
|
||||||
|
const phy = portMatch[3].toLowerCase();
|
||||||
|
const proto = portMatch[4].toLowerCase();
|
||||||
|
ports.push({
|
||||||
|
name: portMatch[1] + portMatch[2],
|
||||||
|
phy,
|
||||||
|
protocol: proto,
|
||||||
|
is_up: phy === "up" && proto === "up",
|
||||||
|
});
|
||||||
|
if (phy === "up") upCount++;
|
||||||
|
else downCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { up_count: upCount, down_count: downCount, ports };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析电源状态
|
||||||
|
* @param {string} output - 命令输出
|
||||||
|
* @returns {object} { normal_count, abnormal_count }
|
||||||
|
*/
|
||||||
|
function parsePowerStatus(output) {
|
||||||
|
const lines = output.split("\n");
|
||||||
|
let normalCount = 0;
|
||||||
|
let abnormalCount = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 匹配电源状态行
|
||||||
|
if (/Normal|Supply|OK/i.test(line)) {
|
||||||
|
normalCount++;
|
||||||
|
} else if (/Absent|Fault|Error/i.test(line)) {
|
||||||
|
abnormalCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { normal_count: normalCount, abnormal_count: abnormalCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理交换机输出
|
||||||
|
* @param {string} output - 原始输出
|
||||||
|
* @returns {string} 清理后的输出
|
||||||
|
*/
|
||||||
|
function cleanOutput(output) {
|
||||||
|
return output
|
||||||
|
// 移除提示符
|
||||||
|
.replace(/<\w+[-A-Za-z0-9]*>/g, "")
|
||||||
|
// 移除分页标记
|
||||||
|
.replace(/---- More ----/g, "")
|
||||||
|
// 移除命令回显
|
||||||
|
.replace(/^[^\r\n]*\r?\n/, "")
|
||||||
|
// 移除空行
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 prompt 风格解析交换机输出
|
||||||
|
* @param {string} output - 原始命令输出
|
||||||
|
* @param {object} context - { catalog_id, device_info }
|
||||||
|
* @returns {object} 结构化指标
|
||||||
|
*/
|
||||||
|
function parseSwitchOutput(output, context = {}) {
|
||||||
|
const cleaned = cleanOutput(output);
|
||||||
|
const lines = cleaned.split("\n");
|
||||||
|
|
||||||
|
const metrics = {
|
||||||
|
cpu_usage: null,
|
||||||
|
memory_usage: null,
|
||||||
|
port_status: [],
|
||||||
|
power_status: [],
|
||||||
|
fan_status: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析 CPU
|
||||||
|
metrics.cpu_usage = parseMetric(cleaned, HUAWEI_H3C_PATTERNS.cpu_usage);
|
||||||
|
|
||||||
|
// 解析内存
|
||||||
|
metrics.memory_usage = parseMetric(cleaned, HUAWEI_H3C_PATTERNS.memory_usage);
|
||||||
|
|
||||||
|
// 解析端口
|
||||||
|
metrics.port_status = parseInterfaceBrief(cleaned);
|
||||||
|
|
||||||
|
// 解析电源
|
||||||
|
metrics.power_status = parsePowerStatus(cleaned);
|
||||||
|
|
||||||
|
// 原始输出(便于调试)
|
||||||
|
metrics._raw_output = cleaned;
|
||||||
|
metrics._parse_errors = [];
|
||||||
|
|
||||||
|
if (metrics.cpu_usage === null) {
|
||||||
|
metrics._parse_errors.push("cpu_usage: 无法解析");
|
||||||
|
}
|
||||||
|
if (metrics.memory_usage === null) {
|
||||||
|
metrics._parse_errors.push("memory_usage: 无法解析");
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseMetric,
|
||||||
|
parseInterfaceBrief,
|
||||||
|
parsePowerStatus,
|
||||||
|
cleanOutput,
|
||||||
|
parseSwitchOutput,
|
||||||
|
};
|
||||||
35
electron/bridges/inspection/storageKeys.cjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检模块 - 存储键定义
|
||||||
|
*
|
||||||
|
* 所有巡检相关的 localStorage 键统一在此集中管理。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PREFIX = "netcatty_inspection_";
|
||||||
|
|
||||||
|
// ---------- 系统配置 ----------
|
||||||
|
exports.STORAGE_KEY_SYS_CONFIG = `${PREFIX}sys_config_v1`;
|
||||||
|
|
||||||
|
// ---------- 设备类型 ----------
|
||||||
|
exports.STORAGE_KEY_DEVICE_CATALOG = `${PREFIX}device_catalog_v1`;
|
||||||
|
|
||||||
|
// ---------- 设备扩展(巡检配置) ----------
|
||||||
|
exports.STORAGE_KEY_DEVICE_EXTENSION = `${PREFIX}device_extension_v1`;
|
||||||
|
|
||||||
|
// ---------- 告警规则 ----------
|
||||||
|
exports.STORAGE_KEY_ALERT_RULES = `${PREFIX}alert_rules_v1`;
|
||||||
|
|
||||||
|
// ---------- 巡检结果 ----------
|
||||||
|
exports.STORAGE_KEY_INSPECTION_RESULTS = `${PREFIX}inspection_results_v1`;
|
||||||
|
|
||||||
|
// ---------- 告警记录 ----------
|
||||||
|
exports.STORAGE_KEY_ALERT_RECORDS = `${PREFIX}alert_records_v1`;
|
||||||
|
|
||||||
|
// ---------- 告警持续计数(防抖动) ----------
|
||||||
|
exports.STORAGE_KEY_ALERT_COUNTERS = `${PREFIX}alert_counters_v1`;
|
||||||
|
|
||||||
|
// ---------- 调度器状态 ----------
|
||||||
|
exports.STORAGE_KEY_SCHEDULER_STATE = `${PREFIX}scheduler_state_v1`;
|
||||||
|
|
||||||
|
exports.STORAGE_KEY_PREFIX = PREFIX;
|
||||||
42
electron/bridges/inspection/store.cjs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检模块 - 本地存储封装
|
||||||
|
*
|
||||||
|
* 使用 NetMesh 的 localStorageAdapter 机制存储巡检数据。
|
||||||
|
* 所有存储 key 统一加前缀 netcatty_inspection_。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { STORAGE_KEY_PREFIX } = require("./storageKeys.cjs");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 localStorage 读取数据(主进程侧通过 IPC 访问,这里提供 key 和默认值)
|
||||||
|
* @param {string} key - 存储键(不含前缀)
|
||||||
|
* @param {*} defaultValue - 默认值
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
function getStore() {
|
||||||
|
// 主进程中,通过全局共享的 storage 方式
|
||||||
|
// 实际存储由 electron 的 localStorageAdapter 管理
|
||||||
|
// 这里提供纯函数式的读写接口,外层调用方负责实际 I/O
|
||||||
|
return {
|
||||||
|
getKey(key) {
|
||||||
|
return `${STORAGE_KEY_PREFIX}${key}`;
|
||||||
|
},
|
||||||
|
parse(raw, defaultValue) {
|
||||||
|
if (!raw) return defaultValue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stringify(value) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getStore,
|
||||||
|
};
|
||||||
143
electron/bridges/inspection/types.cjs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检模块 - 基础类型定义
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 数据库/代码中使用英文枚举键值
|
||||||
|
* - 中文展示名通过 LABELS 映射,界面层统一显示中文
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------- 告警级别 ----------
|
||||||
|
const ALERT_LEVELS = {
|
||||||
|
CRITICAL: "critical",
|
||||||
|
WARNING: "warning",
|
||||||
|
INFO: "info",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALERT_LEVEL_LABELS = {
|
||||||
|
critical: "严重",
|
||||||
|
warning: "警告",
|
||||||
|
info: "提示",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 设备层级 ----------
|
||||||
|
const DEVICE_TIERS = {
|
||||||
|
CORE: "core",
|
||||||
|
AGGREGATION: "aggregation",
|
||||||
|
ACCESS: "access",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_TIER_LABELS = {
|
||||||
|
core: "核心级",
|
||||||
|
aggregation: "重要级",
|
||||||
|
access: "一般",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 各层级默认巡检周期(分钟)
|
||||||
|
const DEVICE_TIER_DEFAULT_INTERVAL = {
|
||||||
|
core: 5,
|
||||||
|
aggregation: 10,
|
||||||
|
access: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 巡检状态 ----------
|
||||||
|
const INSPECTION_STATUS = {
|
||||||
|
SUCCESS: "success",
|
||||||
|
FAILED: "failed",
|
||||||
|
TIMEOUT: "timeout",
|
||||||
|
};
|
||||||
|
|
||||||
|
const INSPECTION_STATUS_LABELS = {
|
||||||
|
success: "成功",
|
||||||
|
failed: "失败",
|
||||||
|
timeout: "超时",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 告警记录状态 ----------
|
||||||
|
const ALERT_RECORD_STATUS = {
|
||||||
|
ACTIVE: "active",
|
||||||
|
ACKNOWLEDGED: "acknowledged",
|
||||||
|
RESOLVED: "resolved",
|
||||||
|
CLOSED: "closed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALERT_RECORD_STATUS_LABELS = {
|
||||||
|
active: "活跃",
|
||||||
|
acknowledged: "已确认",
|
||||||
|
resolved: "已恢复",
|
||||||
|
closed: "已关闭",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 比较操作符 ----------
|
||||||
|
const OPERATORS = {
|
||||||
|
GT: ">",
|
||||||
|
LT: "<",
|
||||||
|
EQ: "==",
|
||||||
|
NE: "!=",
|
||||||
|
GTE: ">=",
|
||||||
|
LTE: "<=",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPERATOR_LABELS = {
|
||||||
|
">": "大于",
|
||||||
|
"<": "小于",
|
||||||
|
"==": "等于",
|
||||||
|
"!=": "不等于",
|
||||||
|
">=": "大于等于",
|
||||||
|
"<=": "小于等于",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 设备类型(采集器标识)----------
|
||||||
|
const DEVICE_CATALOG_TYPES = {
|
||||||
|
LINUX: "linux",
|
||||||
|
WINDOWS: "windows",
|
||||||
|
HUAWEI_H3C_SWITCH: "huawei_h3c_switch",
|
||||||
|
CISCO_SWITCH: "cisco_switch",
|
||||||
|
HUAWEI_FIREWALL: "huawei_firewall",
|
||||||
|
FORTINET_FIREWALL: "fortinet_firewall",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_CATALOG_LABELS = {
|
||||||
|
linux: "Linux 服务器",
|
||||||
|
windows: "Windows 服务器",
|
||||||
|
huawei_h3c_switch: "华为/H3C 交换机",
|
||||||
|
cisco_switch: "Cisco 交换机",
|
||||||
|
huawei_firewall: "华为防火墙",
|
||||||
|
fortinet_firewall: "飞塔防火墙",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 指标中文名称 ----------
|
||||||
|
const METRIC_LABELS = {
|
||||||
|
cpu_usage: "CPU 使用率",
|
||||||
|
memory_usage: "内存使用率",
|
||||||
|
disk_usage: "磁盘使用率",
|
||||||
|
port_status: "端口状态",
|
||||||
|
power_status: "电源状态",
|
||||||
|
fan_status: "风扇状态",
|
||||||
|
online: "设备在线状态",
|
||||||
|
session_count: "会话数",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 系统配置默认值 ----------
|
||||||
|
const SYS_CONFIG_DEFAULTS = {
|
||||||
|
inspection_default_interval: 5, // 全局默认巡检周期(分钟)
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ALERT_LEVELS,
|
||||||
|
ALERT_LEVEL_LABELS,
|
||||||
|
DEVICE_TIERS,
|
||||||
|
DEVICE_TIER_LABELS,
|
||||||
|
DEVICE_TIER_DEFAULT_INTERVAL,
|
||||||
|
INSPECTION_STATUS,
|
||||||
|
INSPECTION_STATUS_LABELS,
|
||||||
|
ALERT_RECORD_STATUS,
|
||||||
|
ALERT_RECORD_STATUS_LABELS,
|
||||||
|
OPERATORS,
|
||||||
|
OPERATOR_LABELS,
|
||||||
|
DEVICE_CATALOG_TYPES,
|
||||||
|
DEVICE_CATALOG_LABELS,
|
||||||
|
METRIC_LABELS,
|
||||||
|
SYS_CONFIG_DEFAULTS,
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
|
const { Client: SSHClient } = require("ssh2");
|
||||||
|
const { buildAlgorithms } = require("../sshAlgorithms.cjs");
|
||||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||||
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
||||||
function decodeLsofFileName(value) {
|
function decodeLsofFileName(value) {
|
||||||
@@ -1006,6 +1008,350 @@ function createSessionOpsApi(ctx) {
|
|||||||
return { success: false, error: 'Session not found or not connected' };
|
return { success: false, error: 'Session not found or not connected' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- NETWORK DEVICE STATS via independent SSH + PTY shell ----------
|
||||||
|
// H3C / Huawei VRP / Cisco IOS / etc. only allow ONE SSH channel per
|
||||||
|
// transport — an exec channel on the interactive conn drops the whole
|
||||||
|
// connection. Open a SEPARATE SSH transport (independent Client, PTY
|
||||||
|
// shell, paging disabled), run display commands, disconnect. This
|
||||||
|
// mirrors the inspection executor pattern — no shared state with the
|
||||||
|
// user's live session.
|
||||||
|
const _v = session.remoteSshVersion || '';
|
||||||
|
const isNetworkDeviceSsh = /^H3C[-_\s]/i.test(_v)
|
||||||
|
|| /^Comware-/i.test(_v)
|
||||||
|
|| /^HUAWEI[-_]/i.test(_v)
|
||||||
|
|| /^VRP-/i.test(_v)
|
||||||
|
|| _v === '-'
|
||||||
|
|| /^Cisco[-_]|^CiscoIOS_/i.test(_v)
|
||||||
|
|| /^RGOS_SSH/i.test(_v)
|
||||||
|
|| /^JUNOS/i.test(_v)
|
||||||
|
|| /H3C/i.test(_v) // 通用 H3C 检测
|
||||||
|
|| /Comware/i.test(_v); // 通用 Comware 检测
|
||||||
|
|
||||||
|
if (isNetworkDeviceSsh && session._statsAuth?.hostname) {
|
||||||
|
try {
|
||||||
|
return await collectNetworkDeviceStats(session);
|
||||||
|
} catch (err) {
|
||||||
|
log("network device stats failed", { sessionId, error: err?.message || String(err) });
|
||||||
|
return { success: false, error: err?.message || String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectNetworkDeviceStats(sess) {
|
||||||
|
const auth = sess._statsAuth;
|
||||||
|
const fs = require('fs');
|
||||||
|
const logPath = require('path').join(require('os').tmpdir(), 'netmesh-stats-debug.log');
|
||||||
|
const dlog = (m) => { try { fs.appendFileSync(logPath, `\n[${new Date().toISOString()}] ${m}`); } catch {} };
|
||||||
|
dlog(`=== collectNetworkDeviceStats START hostname=${auth.hostname}`);
|
||||||
|
|
||||||
|
const connOpts = {
|
||||||
|
host: auth.hostname,
|
||||||
|
port: auth.port || 22,
|
||||||
|
username: auth.username || 'root',
|
||||||
|
readyTimeout: 15000,
|
||||||
|
keepaliveInterval: 0,
|
||||||
|
algorithms: buildAlgorithms(auth.legacyAlgorithms, {
|
||||||
|
skipEcdsaHostKey: auth.skipEcdsaHostKey,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
if (typeof auth.password === 'string' && auth.password.length > 0) {
|
||||||
|
connOpts.password = auth.password;
|
||||||
|
connOpts.tryKeyboard = true;
|
||||||
|
}
|
||||||
|
if (typeof auth.privateKey === 'string' && auth.privateKey.length > 0) {
|
||||||
|
connOpts.privateKey = auth.privateKey;
|
||||||
|
if (auth.passphrase) connOpts.passphrase = auth.passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const client = new SSHClient();
|
||||||
|
let settled = false;
|
||||||
|
let stream = null;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
const finish = (result) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
dlog(`finish: ${JSON.stringify(result).slice(0, 200)}`);
|
||||||
|
try { timer && clearTimeout(timer); } catch {}
|
||||||
|
try { stream && stream.end(); } catch {}
|
||||||
|
try { client.end(); } catch {}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.once('banner', (msg) => dlog(`banner: ${msg.slice(0, 100)}`));
|
||||||
|
client.once('handshake', () => dlog('handshake OK'));
|
||||||
|
client.once('ready', () => {
|
||||||
|
dlog('ready event → opening shell');
|
||||||
|
client.shell({ term: 'vt100', cols: 120, rows: 40 }, (err, s) => {
|
||||||
|
if (err) {
|
||||||
|
dlog(`shell open FAILED: ${err.message}`);
|
||||||
|
finish({ success: false, error: 'shell open failed: ' + err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dlog('shell opened OK');
|
||||||
|
stream = s;
|
||||||
|
collectStatsOverShell(stream, finish, dlog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once('error', (err) => {
|
||||||
|
dlog(`error event: ${err?.message || String(err)} level=${err?.level}`);
|
||||||
|
finish({ success: false, error: err?.message || String(err) });
|
||||||
|
});
|
||||||
|
client.once('close', () => {
|
||||||
|
dlog(`close event settled=${settled}`);
|
||||||
|
if (!settled) finish({ success: false, error: 'SSH connection closed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
try { client.connect(connOpts); dlog('connect() called'); }
|
||||||
|
catch (err) { dlog(`connect() threw: ${err.message}`); finish({ success: false, error: err?.message || String(err) }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStatsOverShell(stream, finish, dlog) {
|
||||||
|
let phaseBuffer = '';
|
||||||
|
let phase = 'init'; // init → disable-paging → cpu → mem → intf → quit → done
|
||||||
|
let output = { cpu: null, memTotal: null, memUsed: null, memPercent: null, ports: [] };
|
||||||
|
let phaseCompleted = false;
|
||||||
|
|
||||||
|
const PROMPT_RE = /^<[\w\-./]+>$|^[\w\-./]+[>#]$/;
|
||||||
|
|
||||||
|
const commands = [
|
||||||
|
{ phase: 'disable-paging', cmd: 'screen-length 0 temporary', noParse: true },
|
||||||
|
{ phase: 'cpu', cmd: 'display cpu-usage' },
|
||||||
|
{ phase: 'mem', cmd: 'display memory' },
|
||||||
|
{ phase: 'intf', cmd: 'display interface brief' },
|
||||||
|
];
|
||||||
|
let cmdIdx = 0;
|
||||||
|
let globalTimeout = null;
|
||||||
|
let initFired = false;
|
||||||
|
let quitSent = false;
|
||||||
|
|
||||||
|
// --- Parsers ---
|
||||||
|
function parseCpuUsage(text) {
|
||||||
|
// H3C Comware 5/7: "7% in last 5 seconds" 或 "3% in last 1 minute"
|
||||||
|
const m = text.match(/(\d+)\s*%\s*in\s+last\s+5\s+seconds/i)
|
||||||
|
|| text.match(/(\d+)\s*%\s*in\s+last\s+1\s+minute/i)
|
||||||
|
|| text.match(/5\s*Seconds\s*[::]\s*(\d+)\s*%/i)
|
||||||
|
|| text.match(/\(5-\d+\s*second\)\s*[::]\s*(\d+)\s*%/i)
|
||||||
|
|| text.match(/CPU\s+utilization[^.]*?(\d{1,3})\s*%/i);
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function parseMemory(text) {
|
||||||
|
// H3C Comware: "Used Rate: 44%" 或 "Memory Using Percentage Is: 21%"
|
||||||
|
const pctM = text.match(/Used\s*Rate\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/i)
|
||||||
|
|| text.match(/Memory\s+Using\s+Percentage\s+Is\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/i);
|
||||||
|
const percent = pctM ? parseFloat(pctM[1]) : null;
|
||||||
|
// bytes 格式: "System Total Memory(bytes): 65546160"
|
||||||
|
const totalBytesM = text.match(/System\s+Total\s+Memory\s*\(\s*bytes\s*\)\s*[::]?\s*(\d+)/i);
|
||||||
|
const usedBytesM = text.match(/Total\s+Used\s+Memory\s*\(\s*bytes\s*\)\s*[::]?\s*(\d+)/i);
|
||||||
|
let total = null, used = null;
|
||||||
|
if (totalBytesM && usedBytesM) {
|
||||||
|
total = parseInt(totalBytesM[1], 10);
|
||||||
|
used = parseInt(usedBytesM[1], 10);
|
||||||
|
}
|
||||||
|
// MB 格式: "Memory Size: xxx MB"
|
||||||
|
const totalM = text.match(/Memory\s*(?:Size|Total)\s*[::]?\s*(\d+)\s*MB/i);
|
||||||
|
const usedM = text.match(/Memory\s*Used\s*[::]?\s*(\d+)\s*MB/i);
|
||||||
|
if (totalM && usedM) {
|
||||||
|
total = parseInt(totalM[1], 10);
|
||||||
|
used = parseInt(usedM[1], 10);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
percent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function parseInterfaceBrief(text) {
|
||||||
|
const ports = [];
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
// H3C 格式: "GE1/0/1 UP 100M(a)" 或 "GigabitEthernet1/0/1 UP UP"
|
||||||
|
const geMatch = line.match(/^(GE\d+\/\d+\/\d+)\s+(UP|DOWN|up|down)/i);
|
||||||
|
if (geMatch) {
|
||||||
|
const phy = geMatch[2].toLowerCase();
|
||||||
|
ports.push({ name: geMatch[1], phy: phy.includes('up') ? 'up' : 'down' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = line.match(/^([A-Za-z][\w\-]+(?:\d+[\/.])*\d+)\s+(UP|DOWN|up|down)/i);
|
||||||
|
if (m && !/^(Name|Interface|Port)\b/i.test(m[1])) {
|
||||||
|
const phy = (m[2] || '').toLowerCase();
|
||||||
|
ports.push({ name: m[1], phy: phy.includes('up') ? 'up' : 'down' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNextCommand() {
|
||||||
|
clearTimeout(globalTimeout);
|
||||||
|
if (cmdIdx >= commands.length) {
|
||||||
|
dlog('ALL COMMANDS DONE → quit');
|
||||||
|
// 标记 quit 阶段:此时交换机关连接是预期行为,不要当错误处理
|
||||||
|
phase = 'quit';
|
||||||
|
quitSent = true;
|
||||||
|
try { stream.write('quit\n'); } catch {}
|
||||||
|
// 交换机 quit 后会立刻关 VTY(close → error),等 1.5s 让它走完再 resolve
|
||||||
|
globalTimeout = setTimeout(() => {
|
||||||
|
dlog('quit timeout → resolve');
|
||||||
|
resolveOutput();
|
||||||
|
}, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = commands[cmdIdx];
|
||||||
|
phase = c.phase;
|
||||||
|
phaseBuffer = '';
|
||||||
|
phaseCompleted = false;
|
||||||
|
dlog(`>>> WRITE: ${c.cmd}`);
|
||||||
|
try { stream.write(c.cmd + '\n'); } catch (err) { dlog(`write error: ${err.message}`); }
|
||||||
|
// 每个 phase 10s timeout
|
||||||
|
globalTimeout = setTimeout(() => {
|
||||||
|
dlog(`phase ${phase} TIMEOUT, skip`);
|
||||||
|
cmdIdx++;
|
||||||
|
setTimeout(sendNextCommand, 200);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOutput() {
|
||||||
|
const upCount = output.ports.filter(p => p.phy === 'up').length;
|
||||||
|
const downCount = output.ports.length - upCount;
|
||||||
|
// 网络设备 display memory 通常只返回百分比(如 21%),没有 total/used MB
|
||||||
|
// 构造假值让 ratioPercent(memUsed, memTotal) 能正确算出百分比
|
||||||
|
let memTotal = output.memTotal;
|
||||||
|
let memUsed = output.memUsed;
|
||||||
|
if (memTotal == null && memUsed == null && output.memPercent != null) {
|
||||||
|
memTotal = 100;
|
||||||
|
memUsed = output.memPercent;
|
||||||
|
}
|
||||||
|
const full = {
|
||||||
|
success: true,
|
||||||
|
stats: {
|
||||||
|
hostname: null, osName: null, kernelRelease: null, uptimeSeconds: null,
|
||||||
|
loadAverage: [],
|
||||||
|
cpu: output.cpu, cpuCores: null, cpuPerCore: [],
|
||||||
|
memTotal, memUsed,
|
||||||
|
memFree: memTotal != null && memUsed != null ? Math.max(0, memTotal - memUsed) : null,
|
||||||
|
memBuffers: null, memCached: null, swapTotal: null, swapUsed: null,
|
||||||
|
topProcesses: [], diskPercent: null, diskUsed: null, diskTotal: null, disks: [],
|
||||||
|
netRxSpeed: 0, netTxSpeed: 0, latencyMs: null, netInterfaces: [],
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
networkDevicePorts: output.ports,
|
||||||
|
networkDevicePortUp: upCount,
|
||||||
|
networkDevicePortDown: downCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
dlog(`RESOLVE ✓ cpu=${output.cpu} memPercent=${output.memPercent} memTotal=${memTotal} memUsed=${memUsed} ports=${output.ports.length} up=${upCount} down=${downCount}`);
|
||||||
|
dlog(`FULL JSON has networkDevicePorts? ${Array.isArray(full.stats.networkDevicePorts)} len=${full.stats.networkDevicePorts?.length}`);
|
||||||
|
finish(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.on('data', (data) => {
|
||||||
|
const text = data.toString('utf-8');
|
||||||
|
phaseBuffer += text;
|
||||||
|
const lastLine = (phaseBuffer.split(/\r?\n/).pop() || '').trimEnd();
|
||||||
|
|
||||||
|
// 处理分页提示符
|
||||||
|
if (phase === 'intf' && (phaseBuffer.includes('-- More --') || phaseBuffer.includes('---- More ----'))) {
|
||||||
|
dlog('检测到分页,发送空格继续');
|
||||||
|
stream.write(' ');
|
||||||
|
// 移除分页提示符避免干扰
|
||||||
|
phaseBuffer = phaseBuffer.replace(/--\s*More\s*--/gi, '').replace(/----\s*More\s*----/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phaseCompleted && PROMPT_RE.test(lastLine)) {
|
||||||
|
phaseCompleted = true;
|
||||||
|
dlog(`PROMPT [${phase}]: ${lastLine}`);
|
||||||
|
|
||||||
|
if (phase === 'init') {
|
||||||
|
// 第一次收到 shell prompt → 触发第一条命令
|
||||||
|
// 关键:不要 cmdIdx++!直接 sendNextCommand 让它从 cmdIdx=0 开始
|
||||||
|
initFired = true;
|
||||||
|
setTimeout(sendNextCommand, 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'cpu') {
|
||||||
|
output.cpu = parseCpuUsage(phaseBuffer);
|
||||||
|
dlog(` → cpu=${output.cpu}`);
|
||||||
|
} else if (phase === 'mem') {
|
||||||
|
const m = parseMemory(phaseBuffer);
|
||||||
|
output.memTotal = m.total; output.memUsed = m.used; output.memPercent = m.percent;
|
||||||
|
dlog(` → memTotal=${m.total} memUsed=${m.used} memPercent=${m.percent}`);
|
||||||
|
} else if (phase === 'intf') {
|
||||||
|
output.ports = parseInterfaceBrief(phaseBuffer);
|
||||||
|
dlog(` → ports=${output.ports.length}`);
|
||||||
|
}
|
||||||
|
cmdIdx++;
|
||||||
|
setTimeout(sendNextCommand, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('close', () => {
|
||||||
|
dlog(`stream close event quitSent=${quitSent}`);
|
||||||
|
clearTimeout(globalTimeout);
|
||||||
|
// quit 后交换机正常关 VTY → 直接 resolve,让后面的 client error 看到 settled=true
|
||||||
|
if (quitSent) {
|
||||||
|
dlog('→ quit-expected close, resolve now');
|
||||||
|
resolveOutput();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Init fallback: 5s 内没收到 shell prompt,也手动触发第一条
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!initFired) {
|
||||||
|
dlog('INIT FALLBACK fire (no shell prompt yet)');
|
||||||
|
initFired = true;
|
||||||
|
sendNextCommand();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// 15s 全局超时(兜底)
|
||||||
|
globalTimeout = setTimeout(() => finish({ success: false, error: 'network device stats timeout' }), 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNetworkDeviceStats(output, finish) {
|
||||||
|
const upCount = output.ports.filter(p => p.phy === 'up').length;
|
||||||
|
const downCount = output.ports.length - upCount;
|
||||||
|
// Map to the standard ServerStats shape the frontend expects.
|
||||||
|
// Fields we cannot derive from H3C/Huawei display commands (processes,
|
||||||
|
// disks, per-core CPU, load average, sparkline samples) are left null.
|
||||||
|
finish({
|
||||||
|
success: true,
|
||||||
|
stats: {
|
||||||
|
hostname: null,
|
||||||
|
osName: null,
|
||||||
|
kernelRelease: null,
|
||||||
|
uptimeSeconds: null,
|
||||||
|
loadAverage: [],
|
||||||
|
cpu: output.cpu,
|
||||||
|
cpuCores: null,
|
||||||
|
cpuPerCore: [],
|
||||||
|
memTotal: output.memTotal,
|
||||||
|
memUsed: output.memUsed,
|
||||||
|
memFree: output.memTotal && output.memUsed ? Math.max(0, output.memTotal - output.memUsed) : null,
|
||||||
|
memBuffers: null,
|
||||||
|
memCached: null,
|
||||||
|
swapTotal: null,
|
||||||
|
swapUsed: null,
|
||||||
|
topProcesses: [],
|
||||||
|
diskPercent: null,
|
||||||
|
diskUsed: null,
|
||||||
|
diskTotal: null,
|
||||||
|
disks: [],
|
||||||
|
netRxSpeed: 0,
|
||||||
|
netTxSpeed: 0,
|
||||||
|
latencyMs: null,
|
||||||
|
netInterfaces: [],
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
// Extended fields for network devices (the POSIX path never sets
|
||||||
|
// these — they are purely for panel rendering):
|
||||||
|
networkDevicePorts: output.ports,
|
||||||
|
networkDevicePortUp: upCount,
|
||||||
|
networkDevicePortDown: downCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isEtSession = session.type === "et";
|
const isEtSession = session.type === "et";
|
||||||
const etUsesExecFallback = isEtSession && session.etStatsAuth?.hasJumpHost;
|
const etUsesExecFallback = isEtSession && session.etStatsAuth?.hasJumpHost;
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,31 @@ function createStartSessionApi(ctx) {
|
|||||||
: false,
|
: false,
|
||||||
cols: options.cols || 80,
|
cols: options.cols || 80,
|
||||||
rows: options.rows || 24,
|
rows: options.rows || 24,
|
||||||
|
// Reusable auth bag for background companion connections (network
|
||||||
|
// device stats PTY shell, etc.). Only non-sensitive fields that the
|
||||||
|
// moshStatsConnection helper also needs — do NOT store the raw options
|
||||||
|
// object here (it contains callbacks, DOM refs, and other non-serializable
|
||||||
|
// junk). This is deliberately best-effort: any auth field missing here
|
||||||
|
// just means the companion skips and stats stay empty — the interactive
|
||||||
|
// shell keeps working regardless.
|
||||||
|
_statsAuth: {
|
||||||
|
hostname: options.host || options.hostname || '',
|
||||||
|
port: options.port || 22,
|
||||||
|
username: options.username || '',
|
||||||
|
password: typeof options.password === 'string' ? options.password : undefined,
|
||||||
|
privateKey: typeof options.privateKey === 'string' ? options.privateKey : undefined,
|
||||||
|
passphrase: typeof options.passphrase === 'string' ? options.passphrase : undefined,
|
||||||
|
authMethod: options.authMethod || undefined,
|
||||||
|
knownHosts: Array.isArray(options.knownHosts) ? options.knownHosts : undefined,
|
||||||
|
verifyHostKeys: options.verifyHostKeys !== false,
|
||||||
|
legacyAlgorithms: !!options.legacyAlgorithms,
|
||||||
|
skipEcdsaHostKey: !!options.skipEcdsaHostKey,
|
||||||
|
algorithmOverrides: options.algorithmOverrides,
|
||||||
|
useSshAgent: options.useSshAgent,
|
||||||
|
identityAgent: options.identityAgent,
|
||||||
|
certificate: typeof options.certificate === 'string' ? options.certificate : undefined,
|
||||||
|
keyId: typeof options.keyId === 'string' ? options.keyId : undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
|
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
|
||||||
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
|
const claim = claimSessionSlot(sessions, sessionId, session, options.bootEpoch);
|
||||||
|
|||||||
@@ -136,6 +136,20 @@ function createExecOnSessionApi(ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [NETWORK-DEVICE GATE] 网络设备(华为 VRP、H3C Comware 等)SSH server 通常
|
||||||
|
// 不支持在同一连接上开多个 channel——exec channel 会触发整个连接被断开。
|
||||||
|
// 从 SSH banner (remoteSshVersion) 检测,如果是网络设备就直接拒绝。
|
||||||
|
const remoteVer = String(session?.remoteSshVersion || '').toLowerCase();
|
||||||
|
const isNetworkDeviceSsh = /^H3C[-_\s]/i.test(session?.remoteSshVersion || '')
|
||||||
|
|| /^Comware-/i.test(session?.remoteSshVersion || '')
|
||||||
|
|| /^HUAWEI[-_]/i.test(session?.remoteSshVersion || '')
|
||||||
|
|| /^VRP-/i.test(session?.remoteSshVersion || '')
|
||||||
|
|| (session?.remoteSshVersion === '-')
|
||||||
|
|| /^Cisco[-_]|^CiscoIOS_/i.test(session?.remoteSshVersion || '');
|
||||||
|
if (isNetworkDeviceSsh) {
|
||||||
|
return { success: false, error: "SSH exec channel not supported on network device (Huawei/H3C/VRP)" };
|
||||||
|
}
|
||||||
|
|
||||||
const conn = await resolveExecConnection(session, sessionId, event);
|
const conn = await resolveExecConnection(session, sessionId, event);
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
if (session?.type === "mosh" && !session.moshStatsAuth && !session.moshStatsConnFailed) {
|
if (session?.type === "mosh" && !session.moshStatsAuth && !session.moshStatsConnFailed) {
|
||||||
|
|||||||
@@ -2528,6 +2528,36 @@ function cleanupAllSessions() {
|
|||||||
terminalOutputChannel?.closeAll?.();
|
terminalOutputChannel?.closeAll?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主机地址查找会话(用于巡检复用连接)
|
||||||
|
*/
|
||||||
|
function findSessionByHost(hostname, port = 22) {
|
||||||
|
const DEBUG = process.env.INSPECTION_DEBUG === "1";
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[TerminalBridge] 查找会话,总会话数:", sessions?.size || 0);
|
||||||
|
}
|
||||||
|
if (!sessions) {
|
||||||
|
if (DEBUG) console.log("[TerminalBridge] sessions 未初始化");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
if (!session) continue;
|
||||||
|
// SSH 会话才有 conn
|
||||||
|
if (!session.conn) continue;
|
||||||
|
// 匹配主机地址(hostname 或 host 字段)
|
||||||
|
const sessionHost = session.hostname || session.host || "";
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[TerminalBridge] SSH会话:", sessionHost,
|
||||||
|
"| port:", session.port, "| 目标:", hostname);
|
||||||
|
}
|
||||||
|
if (sessionHost === hostname) {
|
||||||
|
if (DEBUG) console.log("[TerminalBridge] 匹配到会话:", sessionHost);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init,
|
init,
|
||||||
registerHandlers,
|
registerHandlers,
|
||||||
@@ -2558,4 +2588,5 @@ module.exports = {
|
|||||||
cleanupAllSessions,
|
cleanupAllSessions,
|
||||||
getDefaultShell,
|
getDefaultShell,
|
||||||
validatePath,
|
validatePath,
|
||||||
|
findSessionByHost,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const { safeSend } = require("./ipcUtils.cjs");
|
|||||||
|
|
||||||
const V8_CACHE_OPTIONS = "bypassHeatCheck";
|
const V8_CACHE_OPTIONS = "bypassHeatCheck";
|
||||||
|
|
||||||
|
// 默认产品名称(可通过环境变量 NETCATTY_PRODUCT_NAME 覆盖)
|
||||||
|
const DEFAULT_PRODUCT_NAME = process.env.NETCATTY_PRODUCT_NAME || "NetMesh";
|
||||||
|
|
||||||
function getGlobalShortcutBridge() {
|
function getGlobalShortcutBridge() {
|
||||||
return require("./globalShortcutBridge.cjs");
|
return require("./globalShortcutBridge.cjs");
|
||||||
}
|
}
|
||||||
@@ -1073,6 +1076,17 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("netcatty:window:fullscreen", (event, shouldFullscreen) => {
|
||||||
|
const win = getWindowForIpcEvent(event);
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
const next = typeof shouldFullscreen === 'boolean' ? shouldFullscreen : !win.isFullScreen();
|
||||||
|
debugLog("window:fullscreen", { senderId: event?.sender?.id, windowId: win.webContents?.id, next });
|
||||||
|
win.setFullScreen(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("netcatty:window:close", (event) => {
|
ipcMain.handle("netcatty:window:close", (event) => {
|
||||||
try {
|
try {
|
||||||
if (typeof menuDeps?.isAppLocked === "function" && menuDeps.isAppLocked()) {
|
if (typeof menuDeps?.isAppLocked === "function" && menuDeps.isAppLocked()) {
|
||||||
@@ -1121,17 +1135,39 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
|||||||
|
|
||||||
ipcMain.handle("netcatty:window:setTitle", (event, title) => {
|
ipcMain.handle("netcatty:window:setTitle", (event, title) => {
|
||||||
const win = getWindowForIpcEvent(event);
|
const win = getWindowForIpcEvent(event);
|
||||||
if (!win || win.isDestroyed()) return false;
|
console.log('[windowManager] setTitle called, win:', !!win, 'title:', title);
|
||||||
|
if (!win || win.isDestroyed()) {
|
||||||
|
console.log('[windowManager] setTitle failed: no window');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (typeof menuDeps?.setAppLockWindowTitle === "function") {
|
if (typeof menuDeps?.setAppLockWindowTitle === "function") {
|
||||||
return menuDeps.setAppLockWindowTitle(win, title) === true;
|
return menuDeps.setAppLockWindowTitle(win, title) === true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.log('[windowManager] setAppLockWindowTitle error:', e);
|
||||||
// Ignore title-guard failures and keep normal title behavior.
|
// Ignore title-guard failures and keep normal title behavior.
|
||||||
}
|
}
|
||||||
const value = typeof title === "string" ? title.trim() : "";
|
const value = typeof title === "string" ? title.trim() : "";
|
||||||
|
console.log('[windowManager] setting title to:', value || DEFAULT_PRODUCT_NAME);
|
||||||
try {
|
try {
|
||||||
win.setTitle(value || "Netcatty");
|
win.setTitle(value || DEFAULT_PRODUCT_NAME);
|
||||||
|
console.log('[windowManager] title set successfully');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[windowManager] setTitle error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新窗口标题(当 productName 变化时调用)
|
||||||
|
ipcMain.handle("netcatty:window:updateTitle", (event) => {
|
||||||
|
const win = getWindowForIpcEvent(event);
|
||||||
|
if (!win || win.isDestroyed()) return false;
|
||||||
|
try {
|
||||||
|
// 从存储中读取 productName(如果有的话)
|
||||||
|
// 但这里主进程无法访问渲染进程的 localStorage,所以暂时使用默认值
|
||||||
|
// 实际标题由渲染进程通过 setTitle 设置
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -499,6 +499,17 @@ function createBridgeRegistrar(context) {
|
|||||||
crashLogBridge.registerHandlers(ipcMain);
|
crashLogBridge.registerHandlers(ipcMain);
|
||||||
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
|
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
|
||||||
appLockController?.registerHandlers?.(ipcMain);
|
appLockController?.registerHandlers?.(ipcMain);
|
||||||
|
|
||||||
|
// ----- Inspection (设备巡检) -----
|
||||||
|
const { registerInspectionHandlers } = require("../bridges/inspection/index.cjs");
|
||||||
|
const inspectionStorage = require("../bridges/inspection/inspectionStorage.cjs");
|
||||||
|
inspectionStorage.init(userDataDir);
|
||||||
|
registerInspectionHandlers(ipcMain, {
|
||||||
|
readStorage: (key) => inspectionStorage.readStorage(key),
|
||||||
|
writeStorage: (key, val) => inspectionStorage.writeStorage(key, val),
|
||||||
|
// 传入 sessions 用于复用终端 SSH 连接
|
||||||
|
sessions,
|
||||||
|
});
|
||||||
|
|
||||||
// ZMODEM cancel handler
|
// ZMODEM cancel handler
|
||||||
ipcMain.on("netcatty:zmodem:cancel", (event, payload) => {
|
ipcMain.on("netcatty:zmodem:cancel", (event, payload) => {
|
||||||
|
|||||||
@@ -1092,6 +1092,7 @@ function createPreloadApi(ctx) {
|
|||||||
windowClose: () => ipcRenderer.invoke("netcatty:window:close"),
|
windowClose: () => ipcRenderer.invoke("netcatty:window:close"),
|
||||||
windowIsMaximized: () => ipcRenderer.invoke("netcatty:window:isMaximized"),
|
windowIsMaximized: () => ipcRenderer.invoke("netcatty:window:isMaximized"),
|
||||||
windowIsFullscreen: () => ipcRenderer.invoke("netcatty:window:isFullscreen"),
|
windowIsFullscreen: () => ipcRenderer.invoke("netcatty:window:isFullscreen"),
|
||||||
|
windowFullscreen: (shouldFullscreen) => ipcRenderer.invoke("netcatty:window:fullscreen", shouldFullscreen),
|
||||||
windowFocus: () => ipcRenderer.invoke("netcatty:window:focus"),
|
windowFocus: () => ipcRenderer.invoke("netcatty:window:focus"),
|
||||||
setTerminalKeyboardFocus: (focused) => ipcRenderer.send(
|
setTerminalKeyboardFocus: (focused) => ipcRenderer.send(
|
||||||
"netcatty:window:set-terminal-keyboard-focus",
|
"netcatty:window:set-terminal-keyboard-focus",
|
||||||
@@ -2068,6 +2069,44 @@ function createPreloadApi(ctx) {
|
|||||||
ipcRenderer.on("netcatty:script:session-input", handler);
|
ipcRenderer.on("netcatty:script:session-input", handler);
|
||||||
return () => ipcRenderer.removeListener("netcatty:script:session-input", handler);
|
return () => ipcRenderer.removeListener("netcatty:script:session-input", handler);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ===== Inspection (设备巡检) =====
|
||||||
|
inspection: {
|
||||||
|
getLabels: () => ipcRenderer.invoke("inspection:getLabels"),
|
||||||
|
getSysConfig: () => ipcRenderer.invoke("inspection:getSysConfig"),
|
||||||
|
updateSysConfig: (updates) => ipcRenderer.invoke("inspection:updateSysConfig", updates),
|
||||||
|
getCatalog: () => ipcRenderer.invoke("inspection:getCatalog"),
|
||||||
|
getTiers: () => ipcRenderer.invoke("inspection:getTiers"),
|
||||||
|
getAdapters: () => ipcRenderer.invoke("inspection:getAdapters"),
|
||||||
|
getAllExtensions: () => ipcRenderer.invoke("inspection:getAllExtensions"),
|
||||||
|
getExtension: (hostId) => ipcRenderer.invoke("inspection:getExtension", hostId),
|
||||||
|
upsertExtension: (hostId, data) => ipcRenderer.invoke("inspection:upsertExtension", hostId, data),
|
||||||
|
deleteExtension: (hostId) => ipcRenderer.invoke("inspection:deleteExtension", hostId),
|
||||||
|
getEffectiveInterval: (hostId) => ipcRenderer.invoke("inspection:getEffectiveInterval", hostId),
|
||||||
|
getEnabledDevices: () => ipcRenderer.invoke("inspection:getEnabledDevices"),
|
||||||
|
listRules: () => ipcRenderer.invoke("inspection:listRules"),
|
||||||
|
getRule: (ruleId) => ipcRenderer.invoke("inspection:getRule", ruleId),
|
||||||
|
createRule: (data) => ipcRenderer.invoke("inspection:createRule", data),
|
||||||
|
updateRule: (ruleId, updates) => ipcRenderer.invoke("inspection:updateRule", ruleId, updates),
|
||||||
|
deleteRule: (ruleId) => ipcRenderer.invoke("inspection:deleteRule", ruleId),
|
||||||
|
toggleRule: (ruleId, enabled) => ipcRenderer.invoke("inspection:toggleRule", ruleId, enabled),
|
||||||
|
getLatestAll: () => ipcRenderer.invoke("inspection:getLatestAll"),
|
||||||
|
getLatestByDevice: (hostId, limit) => ipcRenderer.invoke("inspection:getLatestByDevice", hostId, limit),
|
||||||
|
getRecentResults: (limit) => ipcRenderer.invoke("inspection:getRecentResults", limit),
|
||||||
|
listAlerts: (options) => ipcRenderer.invoke("inspection:listAlerts", options),
|
||||||
|
getActiveAlerts: () => ipcRenderer.invoke("inspection:getActiveAlerts"),
|
||||||
|
getAlertStats: () => ipcRenderer.invoke("inspection:getAlertStats"),
|
||||||
|
acknowledgeAlert: (alertId, operator) => ipcRenderer.invoke("inspection:acknowledgeAlert", alertId, operator),
|
||||||
|
resolveAlert: (alertId, reason, operator) => ipcRenderer.invoke("inspection:resolveAlert", alertId, reason, operator),
|
||||||
|
closeAlert: (alertId, note) => ipcRenderer.invoke("inspection:closeAlert", alertId, note),
|
||||||
|
updateAlertNote: (alertId, note) => ipcRenderer.invoke("inspection:updateAlertNote", alertId, note),
|
||||||
|
schedulerStart: () => ipcRenderer.invoke("inspection:scheduler:start"),
|
||||||
|
schedulerStop: () => ipcRenderer.invoke("inspection:scheduler:stop"),
|
||||||
|
schedulerStatus: () => ipcRenderer.invoke("inspection:scheduler:status"),
|
||||||
|
scanNow: (hostId) => ipcRenderer.invoke("inspection:scheduler:scanNow", hostId),
|
||||||
|
scanAllNow: () => ipcRenderer.invoke("inspection:scheduler:scanAllNow"),
|
||||||
|
runInspection: (payload) => ipcRenderer.invoke("inspection:runInspection", payload),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,31 @@ import { Host, Snippet } from '../../domain/models';
|
|||||||
export const INITIAL_HOSTS: Host[] = [];
|
export const INITIAL_HOSTS: Host[] = [];
|
||||||
|
|
||||||
export const INITIAL_SNIPPETS: Snippet[] = [
|
export const INITIAL_SNIPPETS: Snippet[] = [
|
||||||
{ id: '1', label: 'Check Disk Space', command: 'df -h', tags: [] },
|
// === 服务器系统 ===
|
||||||
{ id: '2', label: 'Tail System Log', command: 'tail -f /var/log/syslog', tags: [] },
|
{ id: 'sys-df', label: '磁盘使用', command: 'df -h', tags: ['系统'] },
|
||||||
{ id: '3', label: 'Update Ubuntu', command: 'sudo apt update && sudo apt upgrade -y', tags: [] },
|
{ id: 'sys-free', label: '内存使用', command: 'free -h', tags: ['系统'] },
|
||||||
|
{ id: 'sys-uptime', label: '运行时长', command: 'uptime', tags: ['系统'] },
|
||||||
|
{ id: 'sys-top', label: '进程快照', command: 'top -b -n1 | head -20', tags: ['系统'] },
|
||||||
|
{ id: 'sys-ss', label: '端口监听', command: 'sudo ss -tulpn', tags: ['系统'] },
|
||||||
|
{ id: 'sys-failed', label: '失败服务', command: 'sudo systemctl --failed', tags: ['系统'] },
|
||||||
|
{ id: 'sys-journal', label: '最近日志', command: 'journalctl -n 50 --no-pager', tags: ['系统'] },
|
||||||
|
|
||||||
|
// === 网络诊断 ===
|
||||||
|
{ id: 'net-ping', label: '连通性测试', command: 'ping -c 4 8.8.8.8', tags: ['网络'] },
|
||||||
|
{ id: 'net-trace', label: '路由追踪', command: 'traceroute 8.8.8.8', tags: ['网络'] },
|
||||||
|
{ id: 'net-dns', label: 'DNS 解析', command: 'nslookup google.com', tags: ['网络'] },
|
||||||
|
{ id: 'net-ip', label: '网卡详情', command: 'ip addr show', tags: ['网络'] },
|
||||||
|
{ id: 'net-fw', label: '防火墙规则', command: 'sudo iptables -L -n --line-numbers', tags: ['网络'] },
|
||||||
|
|
||||||
|
// === Docker ===
|
||||||
|
{ id: 'dk-ps', label: '容器列表', command: 'docker ps -a', tags: ['Docker'] },
|
||||||
|
{ id: 'dk-img', label: '镜像列表', command: 'docker images', tags: ['Docker'] },
|
||||||
|
{ id: 'dk-stats', label: '资源使用', command: 'docker stats --no-stream', tags: ['Docker'] },
|
||||||
|
{ id: 'dk-df', label: '磁盘占用', command: 'docker system df', tags: ['Docker'] },
|
||||||
|
{ id: 'dk-prune', label: '清理无用', command: 'docker system prune -af', tags: ['Docker'] },
|
||||||
|
|
||||||
|
// === 更新 / 维护 ===
|
||||||
|
{ id: 'upd-apt', label: 'Ubuntu 更新', command: 'sudo apt update && sudo apt upgrade -y', tags: ['更新'] },
|
||||||
|
{ id: 'upd-yum', label: 'CentOS/RHEL 更新', command: 'sudo yum update -y', tags: ['更新'] },
|
||||||
|
{ id: 'upd-tail-log', label: '实时系统日志', command: 'tail -f /var/log/syslog', tags: ['系统'] },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export type LocaleOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_UI_LOCALE = 'en';
|
export const DEFAULT_UI_LOCALE = 'zh-CN';
|
||||||
|
|
||||||
// Add new languages by appending to this list and providing message dictionaries.
|
// Add new languages by appending to this list and providing message dictionaries.
|
||||||
export const SUPPORTED_UI_LOCALES: LocaleOption[] = [
|
export const SUPPORTED_UI_LOCALES: LocaleOption[] = [
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/** User-editable product name shown in window title / about. */
|
||||||
|
export const STORAGE_KEY_PRODUCT_NAME = 'netcatty_product_name_v1';
|
||||||
|
|
||||||
export const STORAGE_KEY_HOSTS = 'netcatty_hosts_v1';
|
export const STORAGE_KEY_HOSTS = 'netcatty_hosts_v1';
|
||||||
export const STORAGE_KEY_KEYS = 'netcatty_keys_v1';
|
export const STORAGE_KEY_KEYS = 'netcatty_keys_v1';
|
||||||
export const STORAGE_KEY_GROUPS = 'netcatty_groups_v1';
|
export const STORAGE_KEY_GROUPS = 'netcatty_groups_v1';
|
||||||
|
|||||||
268
infrastructure/inspection/inspectionApi.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* 巡检模块 - 前端 API 层
|
||||||
|
*
|
||||||
|
* 通过 window.electronAPI.inspection 调用主进程的巡检 IPC handlers。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------- 类型定义 ----------
|
||||||
|
|
||||||
|
export type AlertLevel = "critical" | "warning" | "info";
|
||||||
|
export type DeviceTier = "core" | "aggregation" | "access";
|
||||||
|
export type InspectionStatus = "success" | "failed" | "timeout";
|
||||||
|
export type AlertRecordStatus = "active" | "acknowledged" | "resolved" | "closed";
|
||||||
|
export type Operator = ">" | "<" | "==" | "!=" | ">=" | "<=";
|
||||||
|
|
||||||
|
export interface InspectionLabels {
|
||||||
|
alert_levels: Record<AlertLevel, string>;
|
||||||
|
device_tiers: Record<DeviceTier, string>;
|
||||||
|
inspection_status: Record<InspectionStatus, string>;
|
||||||
|
alert_record_status: Record<AlertRecordStatus, string>;
|
||||||
|
operators: Record<Operator, string>;
|
||||||
|
device_catalog: Record<string, string>;
|
||||||
|
metrics: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceCatalog {
|
||||||
|
id: string;
|
||||||
|
type_key: string;
|
||||||
|
name: string;
|
||||||
|
vendor: string;
|
||||||
|
adapter_class: string;
|
||||||
|
supported_metrics: string[];
|
||||||
|
default_thresholds: AlertRule[];
|
||||||
|
default_interval: number;
|
||||||
|
description: string;
|
||||||
|
preset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTierInfo {
|
||||||
|
key: DeviceTier;
|
||||||
|
label: string;
|
||||||
|
default_interval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceExtension {
|
||||||
|
host_id: string;
|
||||||
|
/** 主机名(IP 或域名),用于调度器匹配 vault SSH 配置 */
|
||||||
|
hostname?: string | null;
|
||||||
|
catalog_id: string;
|
||||||
|
tier: DeviceTier | null;
|
||||||
|
inspection_interval: number | null;
|
||||||
|
inspection_enabled: boolean;
|
||||||
|
custom_thresholds: AlertRule[];
|
||||||
|
inspection_tags: string[];
|
||||||
|
last_scan_at: number | null;
|
||||||
|
last_scan_status: InspectionStatus | null;
|
||||||
|
last_scan_error: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
effective_interval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
metric: string;
|
||||||
|
operator: Operator;
|
||||||
|
threshold: string;
|
||||||
|
level: AlertLevel;
|
||||||
|
duration: number;
|
||||||
|
scope_type: "all" | "tier" | "device" | "catalog";
|
||||||
|
scope_value: string | string[] | null;
|
||||||
|
enabled: boolean;
|
||||||
|
preset?: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionResult {
|
||||||
|
id: string;
|
||||||
|
job_id: string | null;
|
||||||
|
device_id: string;
|
||||||
|
timestamp: number;
|
||||||
|
online: boolean;
|
||||||
|
status: InspectionStatus;
|
||||||
|
duration_ms: number;
|
||||||
|
metrics: Record<string, any>;
|
||||||
|
alerts: any[];
|
||||||
|
error_message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertRecord {
|
||||||
|
id: string;
|
||||||
|
rule_id: string;
|
||||||
|
rule_name: string;
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
metric: string;
|
||||||
|
metric_value: string;
|
||||||
|
threshold: string;
|
||||||
|
level: AlertLevel;
|
||||||
|
message: string;
|
||||||
|
triggered_at: number;
|
||||||
|
status: AlertRecordStatus;
|
||||||
|
acknowledged_by: string | null;
|
||||||
|
acknowledged_at: number | null;
|
||||||
|
resolved_at: number | null;
|
||||||
|
resolved_reason: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertStats {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
active_critical: number;
|
||||||
|
active_warning: number;
|
||||||
|
active_info: number;
|
||||||
|
acknowledged: number;
|
||||||
|
resolved: number;
|
||||||
|
closed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerStatus {
|
||||||
|
running: boolean;
|
||||||
|
queue_size: number;
|
||||||
|
active_scans: number;
|
||||||
|
concurrency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API 调用 ----------
|
||||||
|
|
||||||
|
const api = (window as any).netcatty?.inspection;
|
||||||
|
|
||||||
|
function isAvailable(): boolean {
|
||||||
|
return !!api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoke(name: string, ...args: any[]): Promise<any> {
|
||||||
|
if (!api || typeof api[name] !== "function") {
|
||||||
|
console.warn(`[Inspection] api.${name} 不可用`);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return api[name](...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 标签 ----------
|
||||||
|
export const inspectionLabelsApi = {
|
||||||
|
isAvailable,
|
||||||
|
getLabels: (): Promise<InspectionLabels> => invoke("getLabels"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 系统配置 ----------
|
||||||
|
export const inspectionSysConfigApi = {
|
||||||
|
get: (): Promise<Record<string, any>> => invoke("getSysConfig"),
|
||||||
|
update: (updates: Record<string, any>) => invoke("updateSysConfig", updates),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 设备类型 ----------
|
||||||
|
export const inspectionCatalogApi = {
|
||||||
|
list: (): Promise<DeviceCatalog[]> => invoke("getCatalog"),
|
||||||
|
getTiers: (): Promise<DeviceTierInfo[]> => invoke("getTiers"),
|
||||||
|
getAdapters: (): Promise<string[]> => invoke("getAdapters"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 设备扩展 ----------
|
||||||
|
export const inspectionDeviceApi = {
|
||||||
|
getAll: (): Promise<Record<string, DeviceExtension>> => invoke("getAllExtensions"),
|
||||||
|
get: (hostId: string): Promise<DeviceExtension | null> => invoke("getExtension", hostId),
|
||||||
|
upsert: (hostId: string, data: Partial<DeviceExtension>): Promise<DeviceExtension> =>
|
||||||
|
invoke("upsertExtension", hostId, data),
|
||||||
|
remove: (hostId: string): Promise<boolean> => invoke("deleteExtension", hostId),
|
||||||
|
getEffectiveInterval: (hostId: string): Promise<number> =>
|
||||||
|
invoke("getEffectiveInterval", hostId),
|
||||||
|
getEnabled: (): Promise<DeviceExtension[]> => invoke("getEnabledDevices"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 告警规则 ----------
|
||||||
|
export const inspectionAlertRuleApi = {
|
||||||
|
list: (): Promise<AlertRule[]> => invoke("listRules"),
|
||||||
|
get: (ruleId: string): Promise<AlertRule | null> => invoke("getRule", ruleId),
|
||||||
|
create: (data: Partial<AlertRule>): Promise<AlertRule> => invoke("createRule", data),
|
||||||
|
update: (ruleId: string, updates: Partial<AlertRule>): Promise<AlertRule | null> =>
|
||||||
|
invoke("updateRule", ruleId, updates),
|
||||||
|
remove: (ruleId: string): Promise<boolean> => invoke("deleteRule", ruleId),
|
||||||
|
toggle: (ruleId: string, enabled: boolean): Promise<AlertRule | null> =>
|
||||||
|
invoke("toggleRule", ruleId, enabled),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 巡检结果 ----------
|
||||||
|
export const inspectionResultApi = {
|
||||||
|
getLatestAll: (): Promise<InspectionResult[]> => invoke("getLatestAll"),
|
||||||
|
getLatestByDevice: (hostId: string, limit = 20): Promise<InspectionResult[]> =>
|
||||||
|
invoke("getLatestByDevice", hostId, limit),
|
||||||
|
getRecent: (limit = 50): Promise<InspectionResult[]> =>
|
||||||
|
invoke("getRecentResults", limit),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 告警记录 ----------
|
||||||
|
export const inspectionAlertApi = {
|
||||||
|
list: (options?: {
|
||||||
|
status?: AlertRecordStatus;
|
||||||
|
level?: AlertLevel;
|
||||||
|
device_id?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<AlertRecord[]> => invoke("listAlerts", options),
|
||||||
|
getActive: (): Promise<AlertRecord[]> => invoke("getActiveAlerts"),
|
||||||
|
getStats: (): Promise<AlertStats> => invoke("getAlertStats"),
|
||||||
|
acknowledge: (alertId: string, operator = "system"): Promise<AlertRecord | null> =>
|
||||||
|
invoke("acknowledgeAlert", alertId, operator),
|
||||||
|
resolve: (
|
||||||
|
alertId: string,
|
||||||
|
reason = "manual",
|
||||||
|
operator = "system",
|
||||||
|
): Promise<AlertRecord | null> =>
|
||||||
|
invoke("resolveAlert", alertId, reason, operator),
|
||||||
|
close: (alertId: string, note = ""): Promise<AlertRecord | null> =>
|
||||||
|
invoke("closeAlert", alertId, note),
|
||||||
|
updateNote: (alertId: string, note: string): Promise<AlertRecord | null> =>
|
||||||
|
invoke("updateAlertNote", alertId, note),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 调度器 ----------
|
||||||
|
// SSHConfig 是用于 SSH 连接的主机配置(hostname/port/username/password/privateKey 等)
|
||||||
|
export type SshHostConfig = Record<string, unknown>;
|
||||||
|
|
||||||
|
export const inspectionSchedulerApi = {
|
||||||
|
start: (hostConfigs?: Record<string, SshHostConfig>): Promise<SchedulerStatus> =>
|
||||||
|
invoke("schedulerStart", hostConfigs ? { hostConfigs } : {}),
|
||||||
|
stop: (): Promise<SchedulerStatus> => invoke("schedulerStop"),
|
||||||
|
status: (): Promise<SchedulerStatus> => invoke("schedulerStatus"),
|
||||||
|
scanNow: (hostId: string): Promise<any> => invoke("scanNow", hostId),
|
||||||
|
scanAllNow: (): Promise<any> => invoke("scanAllNow"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 单次巡检执行 ----------
|
||||||
|
export interface RunInspectionResult {
|
||||||
|
success: boolean;
|
||||||
|
status: string;
|
||||||
|
metrics: Record<string, any>;
|
||||||
|
error: string | null;
|
||||||
|
duration: number;
|
||||||
|
alerts: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostConfigForInspection {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
hostname: string;
|
||||||
|
port?: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
privateKeyContent?: string;
|
||||||
|
passphrase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inspectionExecApi = {
|
||||||
|
/**
|
||||||
|
* 执行一次完整巡检(前端传入完整主机配置)
|
||||||
|
* @param hostId 主机 ID
|
||||||
|
* @param hostConfig 主机连接配置
|
||||||
|
* @param catalogId 设备类型 ID(采集器 ID)
|
||||||
|
*/
|
||||||
|
runInspection: (
|
||||||
|
hostId: string,
|
||||||
|
hostConfig: HostConfigForInspection,
|
||||||
|
catalogId: string
|
||||||
|
): Promise<RunInspectionResult> =>
|
||||||
|
invoke("runInspection", { hostId, hostConfig, catalogId }),
|
||||||
|
};
|
||||||
97
package-lock.json
generated
@@ -55,6 +55,7 @@
|
|||||||
"ajv": "8.18.0",
|
"ajv": "8.18.0",
|
||||||
"ajv-formats": "3.0.1",
|
"ajv-formats": "3.0.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"http-proxy-agent": "^7.0.2",
|
"http-proxy-agent": "^7.0.2",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
"streamdown": "^2.4.0",
|
"streamdown": "^2.4.0",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
|
"three": "^0.186.1",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"turndown-plugin-gfm": "^1.0.2",
|
"turndown-plugin-gfm": "^1.0.2",
|
||||||
"use-stick-to-bottom": "^1.1.3",
|
"use-stick-to-bottom": "^1.1.3",
|
||||||
@@ -91,6 +93,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@types/node": "^25.0.0",
|
"@types/node": "^25.0.0",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||||
"@typescript-eslint/parser": "^8.49.0",
|
"@typescript-eslint/parser": "^8.49.0",
|
||||||
@@ -2362,6 +2365,13 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@electron/asar": {
|
"node_modules/@electron/asar": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz",
|
||||||
@@ -8583,6 +8593,13 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -8771,6 +8788,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.186.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/three/-/three-0.186.0.tgz",
|
||||||
|
"integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.3",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -8791,6 +8830,13 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
@@ -11258,6 +11304,22 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/echarts/node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -12283,6 +12345,13 @@
|
|||||||
"node": "^12.20 || >= 14.13"
|
"node": "^12.20 || >= 14.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -14934,6 +15003,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromark": {
|
"node_modules/micromark": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||||
@@ -18932,6 +19008,12 @@
|
|||||||
"rimraf": "bin.js"
|
"rimraf": "bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.186.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/three/-/three-0.186.1.tgz",
|
||||||
|
"integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tiny-async-pool": {
|
"node_modules/tiny-async-pool": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
||||||
@@ -20175,6 +20257,21 @@
|
|||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zrender/node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/zwitch": {
|
"node_modules/zwitch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run fetch:mosh:dev && npm run fetch:et:dev && npm run lint && concurrently -k \"vite --strictPort\" \"npm:dev:electron\"",
|
"dev": "npm run fetch:mosh:dev && npm run fetch:et:dev && npm run lint && concurrently -k \"vite --strictPort\" \"npm:dev:electron\"",
|
||||||
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
|
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 INSPECTION_DEBUG=1 node electron/launch.cjs",
|
||||||
"clean:vite": "node scripts/clean-vite-cache.cjs",
|
"clean:vite": "node scripts/clean-vite-cache.cjs",
|
||||||
"dev:clean": "npm run clean:vite && npm run dev",
|
"dev:clean": "npm run clean:vite && npm run dev",
|
||||||
"prebuild": "node scripts/copy-monaco.cjs",
|
"prebuild": "node scripts/copy-monaco.cjs",
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
"ajv": "8.18.0",
|
"ajv": "8.18.0",
|
||||||
"ajv-formats": "3.0.1",
|
"ajv-formats": "3.0.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"http-proxy-agent": "^7.0.2",
|
"http-proxy-agent": "^7.0.2",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
@@ -134,6 +135,7 @@
|
|||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
"streamdown": "^2.4.0",
|
"streamdown": "^2.4.0",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
|
"three": "^0.186.1",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
"turndown-plugin-gfm": "^1.0.2",
|
"turndown-plugin-gfm": "^1.0.2",
|
||||||
"use-stick-to-bottom": "^1.1.3",
|
"use-stick-to-bottom": "^1.1.3",
|
||||||
@@ -146,6 +148,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@types/node": "^25.0.0",
|
"@types/node": "^25.0.0",
|
||||||
"@types/react-test-renderer": "^19.1.0",
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||||
"@typescript-eslint/parser": "^8.49.0",
|
"@typescript-eslint/parser": "^8.49.0",
|
||||||
|
|||||||
BIN
public/ditu.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 239 KiB |
BIN
public/icon.png
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 692 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 522 B After Width: | Height: | Size: 23 KiB |
@@ -1,24 +1,58 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="227 238 570 570" width="22" height="22">
|
<defs>
|
||||||
<mask id="cutouts">
|
<!-- 身体渐变:上浅绿下深绿 -->
|
||||||
<rect x="0" y="0" width="1024" height="1024" fill="white"/>
|
<linearGradient id="bodyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
<g transform="translate(161.80 161.80) scale(0.5585)" fill="black">
|
<stop offset="0%" stop-color="#A8E0B0"/>
|
||||||
<!-- Left squinty eye / brow (from logo.svg path #18) -->
|
<stop offset="100%" stop-color="#5BAF7E"/>
|
||||||
<path d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/>
|
</linearGradient>
|
||||||
<!-- Right squinty eye / brow (from logo.svg path #19) -->
|
<!-- 叶子渐变 -->
|
||||||
<path d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/>
|
<linearGradient id="leafGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<!-- Nose + mouth curve (from logo.svg path #28) -->
|
<stop offset="0%" stop-color="#9CCC65"/>
|
||||||
<path d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/>
|
<stop offset="100%" stop-color="#689F38"/>
|
||||||
</g>
|
</linearGradient>
|
||||||
</mask>
|
</defs>
|
||||||
<g mask="url(#cutouts)" fill="black">
|
|
||||||
<g transform="translate(161.80 161.80) scale(0.5585)">
|
<!-- 左耳 -->
|
||||||
<!-- Head + ears -->
|
<path d="M140 200 Q110 160 130 140 Q150 170 165 195 Z" fill="#F8BBD0" stroke="#E8A0B8" stroke-width="2"/>
|
||||||
<path d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/>
|
<!-- 右耳 -->
|
||||||
<!-- Left paw -->
|
<path d="M372 200 Q402 160 382 140 Q362 170 347 195 Z" fill="#F8BBD0" stroke="#E8A0B8" stroke-width="2"/>
|
||||||
<path d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/>
|
|
||||||
<!-- Right paw -->
|
<!-- 头顶小叶子 - 左 -->
|
||||||
<path d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/>
|
<path d="M248 110 Q235 75 210 65 Q225 90 245 108 Z" fill="url(#leafGrad)"/>
|
||||||
</g>
|
<!-- 头顶小叶子 - 右 -->
|
||||||
</g>
|
<path d="M264 108 Q275 70 300 62 Q285 90 268 110 Z" fill="url(#leafGrad)"/>
|
||||||
</svg>
|
|
||||||
|
<!-- 身体主圆形 -->
|
||||||
|
<ellipse cx="256" cy="280" rx="110" ry="115" fill="url(#bodyGrad)"/>
|
||||||
|
|
||||||
|
<!-- 脸部白色区域 -->
|
||||||
|
<ellipse cx="256" cy="250" rx="80" ry="70" fill="#FFFFFF"/>
|
||||||
|
|
||||||
|
<!-- 左眼 -->
|
||||||
|
<circle cx="220" cy="240" r="18" fill="#2D2D2D"/>
|
||||||
|
<circle cx="215" cy="235" r="6" fill="#FFFFFF"/>
|
||||||
|
<!-- 右眼 -->
|
||||||
|
<circle cx="292" cy="240" r="18" fill="#2D2D2D"/>
|
||||||
|
<circle cx="287" cy="235" r="6" fill="#FFFFFF"/>
|
||||||
|
|
||||||
|
<!-- 微笑嘴巴 -->
|
||||||
|
<path d="M240 275 Q256 290 272 275" fill="none" stroke="#4A4A4A" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- 左脸颊 -->
|
||||||
|
<ellipse cx="195" cy="265" rx="14" ry="10" fill="#F48FB1" opacity="0.7"/>
|
||||||
|
<!-- 右脸颊 -->
|
||||||
|
<ellipse cx="317" cy="265" rx="14" ry="10" fill="#F48FB1" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- 左手 -->
|
||||||
|
<ellipse cx="155" cy="310" rx="18" ry="25" fill="url(#bodyGrad)"/>
|
||||||
|
<!-- 右手 -->
|
||||||
|
<ellipse cx="357" cy="310" rx="18" ry="25" fill="url(#bodyGrad)"/>
|
||||||
|
|
||||||
|
<!-- 左脚 -->
|
||||||
|
<ellipse cx="215" cy="390" rx="22" ry="15" fill="#5BAF7E"/>
|
||||||
|
<!-- 右脚 -->
|
||||||
|
<ellipse cx="297" cy="390" rx="22" ry="15" fill="#5BAF7E"/>
|
||||||
|
|
||||||
|
<!-- 头顶高光 -->
|
||||||
|
<ellipse cx="230" cy="185" rx="35" ry="15" fill="#FFFFFF" opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1023 B After Width: | Height: | Size: 2.5 KiB |