import type { NodeMetricApiItem, ServiceMonitorApiItem, ServiceMonitorResultApiItem, ServiceMonitorMutationPayload, ServiceMonitorLimitsApiData, } from "@/api/types"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { MoreVertical, Plus, RefreshCw, Trash2, Edit, Activity, Play, Server, Clock, ArrowLeft, ArrowUp, ArrowDown, Eye, } from "lucide-react"; import toast from "react-hot-toast"; import { DistroIcon, parseDistroFromVersion, getDistroColor } from "@/components/distro-icon"; import { getNodeMetrics, getServiceMonitorList, getServiceMonitorLimits, getServiceMonitorResults, getServiceMonitorLatestResults, createServiceMonitor, updateServiceMonitor, deleteServiceMonitor, runServiceMonitor, } from "@/api"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, } from "@/shadcn-bridge/heroui/modal"; import { Input } from "@/shadcn-bridge/heroui/input"; import { Switch } from "@/shadcn-bridge/heroui/switch"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, } from "@/shadcn-bridge/heroui/table"; import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, } from "@/shadcn-bridge/heroui/dropdown"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Progress } from "@/shadcn-bridge/heroui/progress"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; interface MonitorViewProps { nodeMap: Map; viewMode?: "list" | "grid"; } type RealtimeNodeMetric = { receivedAt: number; cpuUsage: number; memoryUsage: number; diskUsage: number; netInBytes: number; netOutBytes: number; netInSpeed: number; netOutSpeed: number; load1: number; load5: number; load15: number; tcpConns: number; udpConns: number; uptime: number; }; const isSupportedMonitorType = (t: string): t is "tcp" | "icmp" => t === "tcp" || t === "icmp"; const formatTimestamp = (ts: number, rangeMs?: number): string => { const date = new Date(ts); const includeDate = (rangeMs ?? 0) >= 24 * 60 * 60 * 1000; if (includeDate) { return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", }); }; const formatDateTime = (ts: number): string => { return new Date(ts).toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); }; const formatBytes = (bytes: number): string => { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; const formatBytesPerSecond = (bytesPerSecond: number): string => { if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "0 B/s"; const k = 1024; const sizes = ["B/s", "KB/s", "MB/s", "GB/s", "TB/s"]; const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k)); return `${parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; const formatUptime = (seconds: number) => { if (!seconds) return "-"; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); if (days > 0) return `${days} 天 ${hours} 小时`; return `${hours} 小时`; }; const getColorByUsage = (usage?: number) => { if (usage === undefined || usage === null) return "default"; if (usage >= 90) return "danger"; if (usage >= 75) return "warning"; if (usage >= 50) return "primary"; return "success"; }; function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) { const isOnline = node.connectionStatus === "online"; const distro = parseDistroFromVersion(node.version); const distroColor = getDistroColor(distro); return ( {/* Dynamic top gradient bar based on status */}
{/* Decorative background glow */}

{node.name}

{isOnline ? "在线" : "离线"}
CPU {isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"}
内存 {isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"}
↑ 上传
{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}
下载 ↓
{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}
{metric ? formatUptime(metric.uptime) : "-"}
Load: {metric ? metric.load1.toFixed(2) : "-"}
); } type MetricType = | "cpu" | "memory" | "disk" | "network" | "load" | "connections"; const METRICS_MAX_ROWS = 5000; /* ─── Memoized Node Metrics Chart sub-component ─────────────────── */ interface NodeMetricsChartCardProps { rangeMs: number; onRangeChange: (v: number) => void; activeMetricType: MetricType; onMetricTypeChange: (t: MetricType) => void; loading: boolean; error: string | null; truncated: boolean; maxRows: number; data: Array>; nodeId: number | null; onRefresh: (id: number) => void; } const METRIC_TYPE_BUTTONS: { key: MetricType; label: string }[] = [ { key: "cpu", label: "CPU" }, { key: "memory", label: "内存" }, { key: "disk", label: "磁盘" }, { key: "network", label: "网络" }, { key: "load", label: "负载" }, { key: "connections", label: "连接" }, ]; const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({ rangeMs, onRangeChange, activeMetricType, onMetricTypeChange, loading, error, truncated, maxRows, data, nodeId, onRefresh, }: NodeMetricsChartCardProps) { const chartConfig = (() => { switch (activeMetricType) { case "cpu": return { lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }], yAxisLabel: "使用率 (%)" }; case "memory": return { lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }], yAxisLabel: "使用率 (%)" }; case "disk": return { lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }], yAxisLabel: "使用率 (%)" }; case "network": return { lines: [{ dataKey: "netIn", color: "#10b981", name: "入站速度" }, { dataKey: "netOut", color: "#ef4444", name: "出站速度" }], yAxisLabel: "速度 (bytes/s)" }; case "load": return { lines: [{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" }, { dataKey: "load5", color: "#8b5cf6", name: "负载 5m" }, { dataKey: "load15", color: "#f59e0b", name: "负载 15m" }], yAxisLabel: "负载值" }; case "connections": return { lines: [{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" }, { dataKey: "udp", color: "#10b981", name: "UDP 连接" }], yAxisLabel: "连接数" }; } })(); const yAxisTickFormatter = (value: unknown) => { const n = Number(value); if (!Number.isFinite(n)) return ""; switch (activeMetricType) { case "network": return formatBytesPerSecond(n); case "cpu": case "memory": case "disk": return `${n.toFixed(0)}%`; case "load": return n.toFixed(1); case "connections": return String(Math.round(n)); } }; const tooltipFormatter = (value: unknown) => { const n = Number(value); if (!Number.isFinite(n)) return "-"; switch (activeMetricType) { case "network": return formatBytesPerSecond(n); case "cpu": case "memory": case "disk": return `${n.toFixed(1)}%`; case "load": return n.toFixed(2); case "connections": return String(Math.round(n)); } }; return (

节点指标图表

{METRIC_TYPE_BUTTONS.map((item) => ( ))}
{loading ? (
) : error ? (
{error}
) : data.length > 0 ? ( <>
{chartConfig.lines.map((line) => ( ))}
{truncated && (
数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。
)} ) : (
暂无指标数据
)}
); }); const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = { checkerScanIntervalSec: 1, minIntervalSec: 1, defaultIntervalSec: 1, minTimeoutSec: 1, defaultTimeoutSec: 5, maxTimeoutSec: 60, }; export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { const [detailNodeId, setDetailNodeId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const [metrics, setMetrics] = useState([]); const [metricsLoading, setMetricsLoading] = useState(false); const [metricsTruncated, setMetricsTruncated] = useState(false); const [metricsError, setMetricsError] = useState(null); const [activeMetricType, setActiveMetricType] = useState("cpu"); const [metricsRangeMs, setMetricsRangeMs] = useState(60 * 60 * 1000); const [serviceMonitors, setServiceMonitors] = useState< ServiceMonitorApiItem[] >([]); const [monitorsLoading, setMonitorsLoading] = useState(false); const [, setMonitorsError] = useState(null); const [, setLatestResultsError] = useState( null, ); const [monitorResults, setMonitorResults] = useState< Record >({}); const [serviceMonitorLimits, setServiceMonitorLimits] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); const [editingMonitor, setEditingMonitor] = useState(null); const [monitorForm, setMonitorForm] = useState({ name: "", type: "tcp" as "tcp" | "icmp", target: "", intervalSec: DEFAULT_SERVICE_MONITOR_LIMITS.defaultIntervalSec, timeoutSec: DEFAULT_SERVICE_MONITOR_LIMITS.defaultTimeoutSec, nodeId: 0, enabled: true, }); const [submitLoading, setSubmitLoading] = useState(false); const [activeServiceMonitorId, setActiveServiceMonitorId] = useState(null); const [serviceMonitorRangeMs, setServiceMonitorRangeMs] = useState(60 * 60 * 1000); const [accessDenied, setAccessDenied] = useState(null); const [resultsModalOpen, setResultsModalOpen] = useState(false); const [resultsMonitorId, setResultsMonitorId] = useState(null); const [resultsLimit, setResultsLimit] = useState(50); const [resultsLoading, setResultsLoading] = useState(false); const [realtimeNodeStatus, setRealtimeNodeStatus] = useState< Record >({}); const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState< Record >({}); const nodes = useMemo(() => { return Array.from(nodeMap.values()).map((n) => { const status = realtimeNodeStatus[n.id]; if (!status) { return n; } return { ...n, connectionStatus: status, }; }); }, [nodeMap, realtimeNodeStatus]); const onlineNodes = nodes.filter((n) => n.connectionStatus === "online"); const preferredNodeId = onlineNodes[0]?.id ?? nodes[0]?.id ?? 0; const resolvedServiceMonitorLimits = serviceMonitorLimits ?? DEFAULT_SERVICE_MONITOR_LIMITS; const handleRealtimeMessage = useCallback((message: any) => { const nodeId = Number(message?.id ?? 0); if (!nodeId || Number.isNaN(nodeId)) { return; } const type = String(message?.type ?? ""); const payload = message?.data; if (type === "status") { const status = Number(payload); setRealtimeNodeStatus((prev) => ({ ...prev, [nodeId]: status === 1 ? "online" : "offline", })); return; } if (type === "metric") { let raw = payload; if (typeof raw === "string") { try { raw = JSON.parse(raw); } catch { return; } } if (!raw || typeof raw !== "object") { return; } const metric = raw as Record; const receivedAt = Date.now(); const incomingUptime = Number(metric.uptime ?? 0); setRealtimeNodeMetrics((prev) => { const normalized: RealtimeNodeMetric = { receivedAt, cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0), memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0), diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0), netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0), netOutBytes: Number(metric.netOutBytes ?? metric.bytes_transmitted ?? 0), netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0), netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0), load1: Number(metric.load1 ?? 0), load5: Number(metric.load5 ?? 0), load15: Number(metric.load15 ?? 0), tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0), udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0), uptime: incomingUptime || prev[nodeId]?.uptime || 0, }; return { ...prev, [nodeId]: normalized, }; }); setRealtimeNodeStatus((prev) => ({ ...prev, [nodeId]: "online", })); return; } if (type === "info") { setRealtimeNodeStatus((prev) => ({ ...prev, [nodeId]: "online", })); } }, []); const { wsConnected, wsConnecting } = useNodeRealtime({ onMessage: handleRealtimeMessage, enabled: !accessDenied, }); useEffect(() => { if (!selectedNodeId && preferredNodeId > 0) { setSelectedNodeId(preferredNodeId); } }, [preferredNodeId, selectedNodeId]); const loadMetrics = useCallback( async (nodeId: number, options?: { silent?: boolean }) => { const silent = options?.silent ?? false; if (!silent) setMetricsLoading(true); try { const end = Date.now(); const start = end - metricsRangeMs; const response = await getNodeMetrics(nodeId, start, end); if (response.code === 0 && Array.isArray(response.data)) { setAccessDenied(null); setMetricsError(null); setMetricsTruncated(response.data.length >= METRICS_MAX_ROWS); const ordered = [...response.data].sort( (a, b) => a.timestamp - b.timestamp, ); setMetrics(ordered); return; } if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); setMetricsTruncated(false); setMetricsError(null); return; } setMetricsTruncated(false); const msg = response.msg || "加载指标失败"; const isTimeout = msg.toLowerCase().includes("timeout"); const friendlyMsg = isTimeout ? "加载指标超时,请缩小时间范围后重试" : msg; setMetricsError(friendlyMsg); if (!silent) toast.error(friendlyMsg); } catch { setMetricsTruncated(false); if (!silent) setMetricsError("加载指标失败"); } finally { if (!silent) setMetricsLoading(false); } }, [metricsRangeMs], ); const loadServiceMonitors = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; if (!silent) setMonitorsLoading(true); try { const response = await getServiceMonitorList(); if (response.code === 0 && response.data) { setAccessDenied(null); setMonitorsError(null); setServiceMonitors(response.data); return; } if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); setMonitorsError(null); return; } setMonitorsError(response.msg || "加载服务监控失败"); if (!silent) toast.error(response.msg || "加载服务监控失败"); } catch { if (!silent) setMonitorsError("加载服务监控失败"); } finally { if (!silent) setMonitorsLoading(false); } }, []); const loadServiceMonitorLimits = useCallback(async () => { try { const response = await getServiceMonitorLimits(); if (response.code === 0 && response.data) { setAccessDenied(null); setServiceMonitorLimits(response.data); return; } if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); return; } } catch { toast.error("加载服务监控限制失败,已使用默认值"); } }, []); const loadMonitorResults = useCallback( async (monitorId: number, options?: { rangeMs?: number; limit?: number }) => { try { const apiOptions = options?.rangeMs != null ? { start: Date.now() - options.rangeMs, end: Date.now() } : { limit: options?.limit ?? 100 }; const response = await getServiceMonitorResults(monitorId, apiOptions); if (response.code === 0 && response.data) { setMonitorResults((prev) => ({ ...prev, [monitorId]: response.data, })); return; } if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); } } catch { toast.error("加载监控记录失败"); } }, [], ); const loadLatestMonitorResults = useCallback(async () => { try { const response = await getServiceMonitorLatestResults(); if (response.code === 0 && Array.isArray(response.data)) { setAccessDenied(null); setLatestResultsError(null); setMonitorResults((prev) => { const next: Record = { ...prev, }; response.data.forEach((r) => { const monitorId = Number(r?.monitorId ?? 0); if (monitorId <= 0) return; const existing = next[monitorId]; if (existing && existing.length > 1) { const rest = existing.filter((x) => x.id !== r.id); next[monitorId] = [r, ...rest]; return; } next[monitorId] = [r]; }); return next; }); return; } if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); setLatestResultsError(null); return; } setLatestResultsError(response.msg || "加载最新监控结果失败"); } catch { setLatestResultsError("加载最新监控结果失败"); } }, []); const loadResultsForModal = useCallback(async () => { if (!resultsMonitorId) return; setResultsLoading(true); try { await loadMonitorResults(resultsMonitorId, { limit: resultsLimit }); } finally { setResultsLoading(false); } }, [loadMonitorResults, resultsLimit, resultsMonitorId]); useEffect(() => { void loadServiceMonitors(); void loadServiceMonitorLimits(); void loadLatestMonitorResults(); }, [loadLatestMonitorResults, loadServiceMonitorLimits, loadServiceMonitors]); useEffect(() => { const timer = window.setInterval(() => { void loadServiceMonitors({ silent: true }); void loadLatestMonitorResults(); }, 5_000); return () => window.clearInterval(timer); }, [loadLatestMonitorResults, loadServiceMonitors]); useEffect(() => { if (selectedNodeId) { loadMetrics(selectedNodeId); } }, [selectedNodeId, loadMetrics]); useEffect(() => { if (!selectedNodeId) return; const timer = window.setInterval(() => { void loadMetrics(selectedNodeId, { silent: true }); }, 15_000); return () => window.clearInterval(timer); }, [selectedNodeId, loadMetrics]); useEffect(() => { if (!resultsModalOpen || !resultsMonitorId) return; void loadResultsForModal(); }, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]); // Auto-load results for the resolved default monitor when entering detail view useEffect(() => { if (!detailNodeId) return; if (activeServiceMonitorId) return; // user already selected one // Find the first monitor belonging to this node (or panel-level) const firstMonitor = serviceMonitors.find( (m) => m.nodeId === detailNodeId || m.nodeId === 0, ); if (firstMonitor && (!monitorResults[firstMonitor.id] || monitorResults[firstMonitor.id].length <= 1)) { void loadMonitorResults(firstMonitor.id, { rangeMs: serviceMonitorRangeMs }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [detailNodeId, serviceMonitors]); // Reload results for the active service monitor chart when time range changes useEffect(() => { if (!activeServiceMonitorId) return; void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs }); }, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]); const chartData = useMemo(() => metrics.map((m) => ({ time: formatTimestamp(m.timestamp, metricsRangeMs), cpu: m.cpuUsage, memory: m.memoryUsage, disk: m.diskUsage, netIn: m.netInSpeed, netOut: m.netOutSpeed, load1: m.load1, load5: m.load5, load15: m.load15, tcp: m.tcpConns, udp: m.udpConns, })), [metrics, metricsRangeMs]); const handleOpenEditModal = (monitor?: ServiceMonitorApiItem) => { if (monitor) { if (!isSupportedMonitorType(monitor.type)) { toast.error("该监控类型已不支持,仅支持删除"); return; } setEditingMonitor(monitor); setMonitorForm({ name: monitor.name, type: monitor.type as "tcp" | "icmp", target: monitor.target, intervalSec: monitor.intervalSec, timeoutSec: monitor.timeoutSec, nodeId: monitor.nodeId, enabled: monitor.enabled === 1, }); } else { setEditingMonitor(null); setMonitorForm({ name: "", type: "tcp", target: "", intervalSec: resolvedServiceMonitorLimits.defaultIntervalSec, timeoutSec: resolvedServiceMonitorLimits.defaultTimeoutSec, nodeId: 0, enabled: true, }); } setEditModalOpen(true); }; const handleSubmitMonitor = async () => { if (!monitorForm.name || !monitorForm.target) { toast.error("请填写完整信息"); return; } if ( monitorForm.type === "icmp" && (!monitorForm.nodeId || monitorForm.nodeId <= 0) ) { toast.error("ICMP 监控必须选择执行节点"); return; } if (monitorForm.intervalSec < resolvedServiceMonitorLimits.minIntervalSec) { toast.error( `检查间隔不能小于 ${resolvedServiceMonitorLimits.minIntervalSec}s`, ); return; } if ( monitorForm.timeoutSec < resolvedServiceMonitorLimits.minTimeoutSec || monitorForm.timeoutSec > resolvedServiceMonitorLimits.maxTimeoutSec ) { toast.error( `超时时间需在 ${resolvedServiceMonitorLimits.minTimeoutSec}-${resolvedServiceMonitorLimits.maxTimeoutSec}s 范围内`, ); return; } setSubmitLoading(true); try { const payload: ServiceMonitorMutationPayload = { name: monitorForm.name, type: monitorForm.type, target: monitorForm.target, intervalSec: monitorForm.intervalSec, timeoutSec: monitorForm.timeoutSec, nodeId: monitorForm.nodeId, enabled: monitorForm.enabled ? 1 : 0, }; if (editingMonitor) { const response = await updateServiceMonitor({ ...payload, id: editingMonitor.id, }); if (response.code === 0) { toast.success("更新成功"); } else { toast.error(response.msg || "更新失败"); } } else { const response = await createServiceMonitor(payload); if (response.code === 0) { toast.success("创建成功"); } else { toast.error(response.msg || "创建失败"); } } setEditModalOpen(false); void loadServiceMonitors(); void loadLatestMonitorResults(); } catch { toast.error("操作失败"); } finally { setSubmitLoading(false); } }; const handleDeleteMonitor = async (id: number) => { if (!confirm("确定删除该监控项?")) return; try { const response = await deleteServiceMonitor(id); if (response.code === 0) { toast.success("删除成功"); void loadServiceMonitors(); void loadLatestMonitorResults(); } else { toast.error(response.msg || "删除失败"); } } catch { toast.error("删除失败"); } }; const handleRunMonitor = async (id: number) => { try { const response = await runServiceMonitor(id); if (response.code === 0) { const ok = response.data?.success === 1; if (ok) { toast.success("检查成功"); } else { toast.error(response.data?.errorMessage || "检查失败"); } if (response.data) { const latest = response.data; setMonitorResults((prev) => { const next = { ...prev }; const existing = next[id]; if (existing && existing.length > 1) { next[id] = [ latest, ...existing.filter((x) => x.id !== latest.id), ]; } else { next[id] = [latest]; } return next; }); } void loadLatestMonitorResults(); if (resultsMonitorId === id && resultsModalOpen) { void loadResultsForModal(); } } else if (response.code === 403) { setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); } else { toast.error(response.msg || "执行失败"); } } catch { toast.error("执行失败"); } }; const openResultsModal = (monitorId: number) => { setResultsMonitorId(monitorId); setResultsModalOpen(true); }; const getLatestResult = ( monitorId: number, ): ServiceMonitorResultApiItem | null => { const results = monitorResults[monitorId]; return results && results.length > 0 ? results[0] : null; }; const resultsMonitor = resultsMonitorId != null ? serviceMonitors.find((m) => m.id === resultsMonitorId) || null : null; const modalResults = resultsMonitorId != null ? monitorResults[resultsMonitorId] || [] : []; const resolveMonitorIntervalSec = useCallback( (monitor: ServiceMonitorApiItem) => { let interval = Number(monitor.intervalSec ?? 0); if (!interval || interval <= 0) { interval = resolvedServiceMonitorLimits.defaultIntervalSec; } return Math.max(interval, resolvedServiceMonitorLimits.minIntervalSec); }, [resolvedServiceMonitorLimits.defaultIntervalSec, resolvedServiceMonitorLimits.minIntervalSec], ); // Backend batches DB writes every 30s, but latest API reads from in-memory cache. // The cache timestamp reflects the real check time (every ~1s), so stale detection // should still allow for the batch report interval + scan jitter. const SERVICE_MONITOR_REPORT_INTERVAL_MS = 30_000; // matches backend serviceMonitorReportInterval const isResultStale = useCallback( (monitor: ServiceMonitorApiItem, latestResult: ServiceMonitorResultApiItem | null) => { if (monitor.enabled !== 1) { return false; } if (!latestResult || !latestResult.timestamp) { return false; } const intervalMs = resolveMonitorIntervalSec(monitor) * 1000; // Budget = batch report interval + one check interval + scan jitter + grace const budgetMs = SERVICE_MONITOR_REPORT_INTERVAL_MS + intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000; return Date.now() - latestResult.timestamp > budgetMs; }, [resolveMonitorIntervalSec, resolvedServiceMonitorLimits.checkerScanIntervalSec], ); const monitorSummary = useMemo(() => { let disabled = 0; let ok = 0; let fail = 0; let unknown = 0; let stale = 0; serviceMonitors.forEach((m) => { if (m.enabled !== 1) { disabled += 1; return; } const latest = getLatestResult(m.id); if (!latest) { unknown += 1; return; } if (isResultStale(m, latest)) { stale += 1; } if (latest.success === 1) { ok += 1; } else { fail += 1; } }); return { disabled, ok, fail, unknown, stale }; }, [getLatestResult, isResultStale, serviceMonitors]); const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null; const detailRealtimeMetric = detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null; // Service monitors filtered for the detail node (0 = panel-executed, show all in that case) const detailServiceMonitors = detailNodeId != null ? serviceMonitors.filter((m) => m.nodeId === detailNodeId || m.nodeId === 0) : serviceMonitors; return (
{accessDenied && (

监控权限

{accessDenied}
如需使用监控功能,请联系管理员在用户页面授予监控权限。
)} {/* ====== GRID/LIST VIEW ====== */} {!accessDenied && !detailNodeId && ( <>
{wsConnected ? "实时已连接" : wsConnecting ? "实时连接中" : "实时未连接"}
节点在线 {onlineNodes.length}/{nodes.length} 监控 成功 {monitorSummary.ok} / 失败 {monitorSummary.fail} {monitorSummary.stale > 0 && 陈旧 {monitorSummary.stale}}
{viewMode === "grid" ? (
{nodes.map((node) => { const metric = realtimeNodeMetrics[node.id] || null; return ( { setDetailNodeId(node.id); setSelectedNodeId(node.id); }} /> ); })}
) : ( 状态 名称 速率 流量 开机时长 连接数 CPU RAM 存储 操作 {nodes.map((node) => { const metric = realtimeNodeMetrics[node.id] || null; const isOnline = node.connectionStatus === "online"; return (
{node.name}
{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}
{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}
{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}
{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}
{isOnline && metric ? formatUptime(metric.uptime) : "-"}
TCP {isOnline && metric ? metric.tcpConns : "-"}
UDP {isOnline && metric ? metric.udpConns : "-"}
{isOnline && metric ? ( ) : (
)} {isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"}
{isOnline && metric ? ( ) : (
)} {isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"}
{isOnline && metric ? ( ) : (
)} {isOnline && metric ? `${metric.diskUsage.toFixed(1)}%` : "-"}
); })}
)} )} {/* ====== DETAIL VIEW ====== */} {!accessDenied && detailNodeId && ( <> {/* Header */}

{detailNode?.name || `节点 #${detailNodeId}`}

{detailNode?.connectionStatus === "online" ? "在线" : "离线"}
{wsConnected ? "实时已连接" : wsConnecting ? "实时连接中" : "实时未连接"}
{/* Realtime KPI cards */} {detailRealtimeMetric && (
{[ { label: "CPU", value: `${detailRealtimeMetric.cpuUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.cpuUsage) }, { label: "内存", value: `${detailRealtimeMetric.memoryUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.memoryUsage) }, { label: "磁盘", value: `${detailRealtimeMetric.diskUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.diskUsage) }, { label: "↓ 下行速度", value: formatBytesPerSecond(detailRealtimeMetric.netInSpeed), color: "success" as const }, { label: "↑ 上行速度", value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), color: "primary" as const }, { label: "运行时间", value: formatUptime(detailRealtimeMetric.uptime), color: "default" as const }, ].map((item) => ( {item.label} {item.value} ))}
)} {/* Node metrics chart */} {/* Service monitors chart – same style as node metrics */} {(() => { // Resolve which monitor is active const resolvedActiveMonitor = detailServiceMonitors.find((m) => m.id === activeServiceMonitorId) || detailServiceMonitors[0] || null; const resolvedActiveMonitorId = resolvedActiveMonitor?.id ?? null; const activeLatestResult = resolvedActiveMonitorId != null ? getLatestResult(resolvedActiveMonitorId) : null; const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false; const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : []; const activeLatencyData = [...activeResults] .map((r) => ({ time: formatTimestamp(r.timestamp, serviceMonitorRangeMs), latency: r.success === 1 ? r.latencyMs : null, success: r.success, })); return (

服务监控图表

{resolvedActiveMonitorId != null && ( )}
{monitorsLoading ? (
) : detailServiceMonitors.length > 0 ? ( <> {/* Monitor switcher buttons – like metric type tabs */}
{detailServiceMonitors.map((monitor) => { const lr = getLatestResult(monitor.id); const statusColor = monitor.enabled !== 1 ? "default" : !lr ? "default" : lr.success === 1 ? "success" : "danger"; const isActive = monitor.id === resolvedActiveMonitorId; return ( ); })}
{/* Active monitor info bar */} {resolvedActiveMonitor && (
{resolvedActiveMonitor.type.toUpperCase()} {resolvedActiveMonitor.target} 每秒测试,30秒上报 {activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? ( {activeLatestResult.latencyMs.toFixed(0)}ms ) : null} {activeStale ? 陈旧 : null} {resolvedActiveMonitor.enabled !== 1 ? 已禁用 : null}
} onPress={() => resolvedActiveMonitorId != null && handleRunMonitor(resolvedActiveMonitorId)}>立即检查 } onPress={() => resolvedActiveMonitorId != null && openResultsModal(resolvedActiveMonitorId)}>查看记录 } onPress={() => resolvedActiveMonitor && handleOpenEditModal(resolvedActiveMonitor)}>编辑 } onPress={() => resolvedActiveMonitorId != null && handleDeleteMonitor(resolvedActiveMonitorId)}>删除
)} {/* Chart area – same h-64 as node metrics */} {activeLatencyData.length > 0 ? (
`${Math.round(v)}ms`} /> [`${Number(value).toFixed(0)}ms`, "延迟"]} />
) : (
暂无检查记录
)} ) : (
暂无服务监控,点击"添加监控"创建
)}
); })()} )} { setResultsModalOpen(false); setResultsMonitorId(null); }} >
监控记录 {resultsMonitor?.name ? ` - ${resultsMonitor.name}` : ""}
{resultsMonitor?.nodeId && resultsMonitor.nodeId > 0 ? (
节点:{" "} {nodeMap.get(resultsMonitor.nodeId)?.name || resultsMonitor.nodeId}
) : (
面板执行
)} {modalResults.length > 0 ? (
最新检查:{" "} {formatDateTime(modalResults[0].timestamp)}
) : null}
{resultsMonitorId != null ? (
) : null}
{resultsLoading ? (
) : modalResults.length > 0 ? ( 时间 结果 延迟 错误 {modalResults.map((r) => ( {formatDateTime(r.timestamp)} {r.success === 1 ? ( 成功 ) : ( 失败 )} {Number.isFinite(r.latencyMs) ? `${r.latencyMs.toFixed(0)}ms` : "-"} {r.errorMessage || "-"} ))}
) : (
暂无监控记录
)}
setEditModalOpen(false)}> {editingMonitor ? "编辑监控" : "添加监控"} setMonitorForm((f) => ({ ...f, name: e.target.value })) } /> setMonitorForm((f) => ({ ...f, target: e.target.value })) } />
setMonitorForm((f) => ({ ...f, intervalSec: Number(e.target.value) || resolvedServiceMonitorLimits.defaultIntervalSec, })) } /> resolvedServiceMonitorLimits.maxTimeoutSec ? `需在 ${resolvedServiceMonitorLimits.minTimeoutSec}-${resolvedServiceMonitorLimits.maxTimeoutSec}s 范围内` : undefined } isInvalid={ monitorForm.timeoutSec < resolvedServiceMonitorLimits.minTimeoutSec || monitorForm.timeoutSec > resolvedServiceMonitorLimits.maxTimeoutSec } label="超时时间(秒)" type="number" value={String(monitorForm.timeoutSec)} onChange={(e) => setMonitorForm((f) => ({ ...f, timeoutSec: Number(e.target.value) || resolvedServiceMonitorLimits.defaultTimeoutSec, })) } />
setMonitorForm((f) => ({ ...f, enabled: v })) } /> 启用
); }