2026-10-15 13:53:04 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* useMonitoringData - 运行监控大屏数据 hook
|
|
|
|
|
|
*
|
|
|
|
|
|
* 数据源(全部来自 NetMesh 真实 API):
|
|
|
|
|
|
* - hosts: props 传入(vault 里的设备列表)
|
|
|
|
|
|
* - inspection.getLatestAll() → 每台设备最新巡检结果(含 metrics:cpu/memory/online)
|
|
|
|
|
|
* - inspectionAlertApi.getActive() → 活动告警列表
|
|
|
|
|
|
* - inspectionAlertApi.getStats() → 告警统计
|
|
|
|
|
|
* - inspectionRuleApi.list() → 巡检规则(用于任务状态统计)
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { useEffect, useState, useCallback, useRef, useMemo } 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;
|
|
|
|
|
|
|
|
|
|
|
|
// 告警规则 & 派生阈值
|
|
|
|
|
|
rules: any[];
|
|
|
|
|
|
thresholds: {
|
|
|
|
|
|
cpuCritical: number;
|
|
|
|
|
|
cpuWarning: number;
|
|
|
|
|
|
memCritical: number;
|
|
|
|
|
|
memWarning: number;
|
|
|
|
|
|
diskCritical: number;
|
|
|
|
|
|
diskWarning: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 操作
|
|
|
|
|
|
refresh: () => Promise<void>;
|
|
|
|
|
|
loading: boolean;
|
2026-10-19 21:11:57 +08:00
|
|
|
|
|
|
|
|
|
|
// 浏览器远程访问环境下实际使用的 hosts(Electron 环境 = props.hosts)
|
|
|
|
|
|
effectiveHosts: Host[];
|
2026-10-15 13:53:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 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 [rules, setRules] = useState<any[]>([]);
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// 浏览器远程访问环境:从 /api/devices 构建虚拟 hosts 列表(Electron vault 不可用)
|
|
|
|
|
|
const [remoteHosts, setRemoteHosts] = useState<Host[]>([]);
|
|
|
|
|
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
|
|
|
|
|
const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || "";
|
|
|
|
|
|
|
|
|
|
|
|
// Electron 本地:同步 vault hosts 给主进程(供远程浏览器 API 读)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (IS_REMOTE) return; // 远程浏览器不触发
|
|
|
|
|
|
const nc = (window as any).netcatty;
|
|
|
|
|
|
if (!nc?.inspection) return;
|
|
|
|
|
|
if (hosts.length === 0) return;
|
|
|
|
|
|
console.log('[useMonitoringData] syncing vault hosts to main process:', hosts.length);
|
|
|
|
|
|
nc.inspection.saveVaultHosts(hosts).then((r: any) => {
|
|
|
|
|
|
console.log('[useMonitoringData] saveVaultHosts result:', r);
|
|
|
|
|
|
}).catch((e: any) => {
|
|
|
|
|
|
console.warn('[useMonitoringData] saveVaultHosts error:', e);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [hosts, IS_REMOTE]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!IS_REMOTE) return;
|
|
|
|
|
|
console.log('[monitoring] REMOTE ENV - props.hosts.length=', hosts.length, 'remoteHosts.length=', remoteHosts.length);
|
|
|
|
|
|
if (hosts.length > 0) {
|
|
|
|
|
|
console.warn('[monitoring] REMOTE ENV 但 props.hosts 有值!跳过 remote hosts 加载,hosts[0]=', hosts[0]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const loadRemoteHosts = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/devices?token=${encodeURIComponent(REMOTE_TOKEN)}`);
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const list: Host[] = (data.devices || []).map((d: any) => ({
|
|
|
|
|
|
id: d.host_id,
|
|
|
|
|
|
label: d.label || d.name || d.hostname || d.host_id,
|
|
|
|
|
|
hostname: d.hostname || d.name || "",
|
|
|
|
|
|
port: 22,
|
|
|
|
|
|
username: "",
|
|
|
|
|
|
distro: d.distro || d.catalog_type || d.catalog_id || "",
|
|
|
|
|
|
type: d.type || "",
|
|
|
|
|
|
catalogId: d.catalog_id || "",
|
|
|
|
|
|
tags: [],
|
|
|
|
|
|
os: "linux" as const,
|
|
|
|
|
|
group: "",
|
|
|
|
|
|
}));
|
|
|
|
|
|
setRemoteHosts(list);
|
|
|
|
|
|
hostsRef.current = list;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("[monitoring] remote hosts load failed:", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
void loadRemoteHosts();
|
|
|
|
|
|
}, [IS_REMOTE, REMOTE_TOKEN, hosts.length]);
|
|
|
|
|
|
|
|
|
|
|
|
// 实际使用的 hosts:Electron 用 props,浏览器用远程加载的
|
|
|
|
|
|
const effectiveHosts = IS_REMOTE && hosts.length === 0 ? remoteHosts : hosts;
|
|
|
|
|
|
|
2026-10-15 13:53:04 +08:00
|
|
|
|
const refresh = useCallback(async (silent = false) => {
|
|
|
|
|
|
if (loadingRef.current) {
|
|
|
|
|
|
console.log('[monitoring] refresh() 正在执行中,跳过');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
loadingRef.current = true;
|
|
|
|
|
|
if (!silent) 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);
|
|
|
|
|
|
// 浅比较:数据没变化就不 setState,避免触发无意义重渲
|
|
|
|
|
|
setAlarmTrend((prev) => {
|
|
|
|
|
|
if (prev.length === trend.length && prev.every((p, i) => p.date === trend[i].date && p.count === trend[i].count)) return prev;
|
|
|
|
|
|
return trend;
|
|
|
|
|
|
});
|
|
|
|
|
|
writeLS(LS_TREND, trend);
|
|
|
|
|
|
} catch { /* 忽略趋势拉取失败 */ }
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.log('[monitoring] alerts failed:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 3. 调度器状态 + 设备扩展 + 告警规则 → 任务统计 + 任务设备列表 + thresholds =====
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [schedulerStatus, enabledDevices, allExtensions, rulesResult] = await Promise.all([
|
|
|
|
|
|
inspectionSchedulerApi.status(),
|
|
|
|
|
|
inspectionDeviceApi.getEnabled(),
|
|
|
|
|
|
inspectionDeviceApi.getAll(),
|
|
|
|
|
|
inspectionAlertRuleApi.list().catch(() => []),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
setRules(rulesResult || []);
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
console.log('[monitoring] 条件满足,准备启动调度器');
|
2026-10-15 13:53:04 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const hostConfigs: Record<string, Record<string, unknown>> = {};
|
2026-10-19 21:11:57 +08:00
|
|
|
|
const allExt = allExtensions || {};
|
|
|
|
|
|
for (const [hostId, ext] of Object.entries(allExt)) {
|
|
|
|
|
|
if (!ext || !ext.inspection_enabled) continue;
|
|
|
|
|
|
const host = hostsRef.current.find((h) => h.id === hostId);
|
2026-10-15 13:53:04 +08:00
|
|
|
|
if (!host) {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
console.log('[monitoring] vault 中未找到 host_id:', hostId, ',跳过');
|
2026-10-15 13:53:04 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!host.username) {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
console.log('[monitoring] 设备', host.label || host.hostname || hostId, '缺少 username,跳过');
|
2026-10-15 13:53:04 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-19 21:11:57 +08:00
|
|
|
|
hostConfigs[hostId] = buildSshConfig(host, keysRef.current);
|
2026-10-15 13:53:04 +08:00
|
|
|
|
}
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// 诊断:打印 hostConfigs 样本看 label 有没有正确传递
|
|
|
|
|
|
const sample = Object.entries(hostConfigs).slice(0, 3).map(([id, c]) => ({ id, label: c.label, host: c.host, username: c.username }));
|
|
|
|
|
|
console.log('[monitoring] hostConfigs 样本:', JSON.stringify(sample));
|
2026-10-15 13:53:04 +08:00
|
|
|
|
if (Object.keys(hostConfigs).length === 0) {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
console.log('[monitoring] 没有可巡检的设备(vault 中匹配不到或凭据不完整)');
|
2026-10-15 13:53:04 +08:00
|
|
|
|
} else {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备(running=' + schedulerStatus.running + ')');
|
|
|
|
|
|
// 不管 running 与否都传 hostConfigs 过去让主进程更新,
|
|
|
|
|
|
// 主进程 execFn 通过模块级 _hostConfigs 引用动态读最新值
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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;
|
|
|
|
|
|
if (!silent) setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 初始加载 + 定期刷新(每 30 秒)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
refresh(false);
|
|
|
|
|
|
const id = setInterval(() => refresh(true), 30000);
|
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
|
}, [refresh]);
|
|
|
|
|
|
|
|
|
|
|
|
// 事件驱动:告警变化立即刷新(静默刷新,不显示 loading 避免闪动)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const api = (window as any).netcatty?.inspection;
|
|
|
|
|
|
if (!api?.onAlertChanged) return;
|
|
|
|
|
|
// 前端侧 500ms debounce
|
|
|
|
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
const handler = () => {
|
|
|
|
|
|
if (timer) clearTimeout(timer);
|
|
|
|
|
|
timer = setTimeout(() => refresh(true), 500);
|
|
|
|
|
|
};
|
|
|
|
|
|
const unsub = api.onAlertChanged(handler);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (timer) clearTimeout(timer);
|
|
|
|
|
|
try { unsub?.(); } catch (_) {}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [refresh]);
|
|
|
|
|
|
|
|
|
|
|
|
// 规则变化 → 立刻 refresh(让 thresholds 同步)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const api = (window as any).netcatty?.inspection;
|
|
|
|
|
|
if (!api?.onRuleChanged) return;
|
|
|
|
|
|
const handler = () => refresh(true);
|
|
|
|
|
|
const unsub = api.onRuleChanged(handler);
|
|
|
|
|
|
return () => { try { unsub?.(); } catch (_) {} };
|
|
|
|
|
|
}, [refresh]);
|
|
|
|
|
|
|
|
|
|
|
|
// hosts 变化时,把在线状态默认设为 true(没巡检数据前)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setOnlineMap((prev) => {
|
|
|
|
|
|
const next: Record<string, boolean> = { ...prev };
|
|
|
|
|
|
let changed = false;
|
2026-10-19 21:11:57 +08:00
|
|
|
|
for (const h of effectiveHosts) {
|
2026-10-15 13:53:04 +08:00
|
|
|
|
if (next[h.id] === undefined) {
|
|
|
|
|
|
next[h.id] = true; // 假设在线,等巡检结果覆盖
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return changed ? next : prev;
|
|
|
|
|
|
});
|
2026-10-19 21:11:57 +08:00
|
|
|
|
}, [effectiveHosts]);
|
2026-10-15 13:53:04 +08:00
|
|
|
|
|
|
|
|
|
|
// 从告警规则派生阈值(和 alertRuleStore 默认规则对齐)
|
|
|
|
|
|
const thresholds = useMemo(() => {
|
|
|
|
|
|
let cpuCritical = 90, cpuWarning = 80;
|
|
|
|
|
|
let memCritical = 85, memWarning = 75;
|
|
|
|
|
|
let diskCritical = 90, diskWarning = 80;
|
|
|
|
|
|
for (const r of rules) {
|
|
|
|
|
|
if (!r.enabled) continue;
|
|
|
|
|
|
// 排除 scope_type='catalog' 的设备专属规则(会覆盖全局通用规则)
|
|
|
|
|
|
if (r.scope_type && r.scope_type !== 'all') continue;
|
|
|
|
|
|
const t = Number(r.threshold);
|
|
|
|
|
|
if (Number.isNaN(t)) continue;
|
|
|
|
|
|
if (r.metric === 'cpu_usage') {
|
|
|
|
|
|
if (r.level === 'critical') cpuCritical = t;
|
|
|
|
|
|
else if (r.level === 'warning') cpuWarning = t;
|
|
|
|
|
|
} else if (r.metric === 'memory_usage') {
|
|
|
|
|
|
if (r.level === 'critical') memCritical = t;
|
|
|
|
|
|
else if (r.level === 'warning') memWarning = t;
|
|
|
|
|
|
} else if (r.metric === 'disk_usage') {
|
|
|
|
|
|
if (r.level === 'critical') diskCritical = t;
|
|
|
|
|
|
else if (r.level === 'warning') diskWarning = t;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { cpuCritical, cpuWarning, memCritical, memWarning, diskCritical, diskWarning };
|
|
|
|
|
|
}, [rules]);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
cpuMap,
|
|
|
|
|
|
memMap,
|
|
|
|
|
|
onlineMap,
|
|
|
|
|
|
timestampMap,
|
|
|
|
|
|
alerts,
|
|
|
|
|
|
alarmTrend,
|
|
|
|
|
|
alertStats,
|
|
|
|
|
|
taskStats,
|
|
|
|
|
|
taskDevices,
|
|
|
|
|
|
schedulerRunning,
|
|
|
|
|
|
rules,
|
|
|
|
|
|
thresholds,
|
|
|
|
|
|
refresh,
|
|
|
|
|
|
loading,
|
2026-10-19 21:11:57 +08:00
|
|
|
|
effectiveHosts,
|
2026-10-15 13:53:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|