fix(monitor): unify realtime state and ignore stale requests (#571)

This commit is contained in:
sagit
2026-11-09 18:21:18 +08:00
committed by GitHub
parent 743bf6f278
commit a784350eaf
2 changed files with 125 additions and 166 deletions
+79 -23
View File
@@ -1,6 +1,7 @@
import type { MonitorNodeApiItem } from "@/api/types"; import type { MonitorNodeApiItem } from "@/api/types";
import type { RealtimeNodeMetric } from "@/pages/node/monitor-view";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
RefreshCw, RefreshCw,
@@ -44,14 +45,23 @@ export default function MonitorPage() {
const [viewMode, setViewMode] = useState<"list" | "grid">("list"); const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes"); const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState< const realtimeNodeStatusRef = useRef(new Map<number, { status: number }>());
Record<number, "online" | "offline"> const nodesRequestIdRef = useRef(0);
>({});
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState< const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<
Record<number, any> Record<number, RealtimeNodeMetric>
>({}); >({});
const handleRealtimeMessage = useCallback((message: any) => { const updateNodeStatus = useCallback((nodeId: number, status: number) => {
realtimeNodeStatusRef.current.set(nodeId, { status });
setNodes((prev) =>
prev.map((node) =>
Number(node.id) === nodeId ? { ...node, status } : node,
),
);
}, []);
const handleRealtimeMessage = useCallback(
(message: any) => {
const nodeId = Number(message?.id ?? 0); const nodeId = Number(message?.id ?? 0);
if (!nodeId || Number.isNaN(nodeId)) return; if (!nodeId || Number.isNaN(nodeId)) return;
@@ -60,12 +70,13 @@ export default function MonitorPage() {
const payload = message?.data; const payload = message?.data;
if (type === "status") { if (type === "status") {
const status = Number(payload); updateNodeStatus(nodeId, Number(payload) === 1 ? 1 : 0);
setRealtimeNodeStatus((prev) => ({ return;
...prev, }
[nodeId]: status === 1 ? "online" : "offline",
})); if (type === "info") {
updateNodeStatus(nodeId, 1);
return; return;
} }
@@ -81,36 +92,70 @@ export default function MonitorPage() {
} }
} }
if (!raw || typeof raw !== "object") return; if (!raw || typeof raw !== "object") return;
const metric = raw as any; const metric = raw as Record<string, unknown>;
const receivedAt = Date.now();
const incomingUptime = Number(metric.uptime ?? 0);
setRealtimeNodeMetrics((prev) => ({ setRealtimeNodeMetrics((prev) => ({
...prev, ...prev,
[nodeId]: { [nodeId]: {
receivedAt,
cpuUsage: Number(metric.cpuUsage ?? metric.cpu_usage ?? 0), 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), netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0),
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_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), tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0), udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
uptime: incomingUptime || prev[nodeId]?.uptime || 0,
}, },
})); }));
updateNodeStatus(nodeId, 1);
} }
}, []); },
[updateNodeStatus],
);
useNodeRealtime({ const { wsConnected, wsConnecting } = useNodeRealtime({
onMessage: handleRealtimeMessage, onMessage: handleRealtimeMessage,
enabled: true, enabled: true,
}); });
const loadNodes = useCallback(async (options?: { silent?: boolean }) => { const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
const requestId = ++nodesRequestIdRef.current;
const statusAtRequest = new Map(realtimeNodeStatusRef.current);
if (!silent) setNodesLoading(true); if (!silent) setNodesLoading(true);
try { try {
const response = await getMonitorNodes(); const response = await getMonitorNodes();
// A newer refresh or poll owns the page state, even if it finishes first.
if (requestId !== nodesRequestIdRef.current) return;
if (response.code === 0 && Array.isArray(response.data)) { if (response.code === 0 && Array.isArray(response.data)) {
setNodesError(null); setNodesError(null);
setNodes(response.data); // A fresh snapshot repairs missed WS events. Events received while the
// request was in flight are newer than that snapshot and must win.
setNodes(
response.data.map((node) => {
const nodeId = Number(node.id);
const latest = realtimeNodeStatusRef.current.get(nodeId);
return latest && latest !== statusAtRequest.get(nodeId)
? { ...node, status: latest.status }
: node;
}),
);
return; return;
} }
@@ -124,14 +169,21 @@ export default function MonitorPage() {
if (!silent) toast.error(response.msg || "加载节点失败"); if (!silent) toast.error(response.msg || "加载节点失败");
} catch { } catch {
if (!silent) toast.error("加载节点失败"); if (requestId === nodesRequestIdRef.current && !silent) {
toast.error("加载节点失败");
}
} finally { } finally {
if (!silent) setNodesLoading(false); // A silent poll may supersede a foreground refresh that showed loading.
if (requestId === nodesRequestIdRef.current) setNodesLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
void loadNodes(); void loadNodes();
return () => {
nodesRequestIdRef.current += 1;
};
}, [loadNodes]); }, [loadNodes]);
useEffect(() => { useEffect(() => {
@@ -148,14 +200,12 @@ export default function MonitorPage() {
.map((n) => ({ .map((n) => ({
id: Number(n.id), id: Number(n.id),
name: String(n.name ?? ""), name: String(n.name ?? ""),
connectionStatus: connectionStatus: n.status === 1 ? "online" : "offline",
realtimeNodeStatus[Number(n.id)] ||
(n.status === 1 ? "online" : "offline"),
version: n.version, version: n.version,
})); }));
return new Map<number, MonitorNode>(list.map((n) => [n.id, n])); return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
}, [nodes, realtimeNodeStatus]); }, [nodes]);
const aggregateMetrics = useMemo(() => { const aggregateMetrics = useMemo(() => {
let totalCpu = 0; let totalCpu = 0;
@@ -326,7 +376,13 @@ export default function MonitorPage() {
</div> </div>
{activeTab === "nodes" ? ( {activeTab === "nodes" ? (
<MonitorView nodeMap={nodeMap} viewMode={viewMode} /> <MonitorView
nodeMap={nodeMap}
realtimeNodeMetrics={realtimeNodeMetrics}
viewMode={viewMode}
wsConnected={wsConnected}
wsConnecting={wsConnecting}
/>
) : ( ) : (
<TunnelMonitorView viewMode={viewMode} /> <TunnelMonitorView viewMode={viewMode} />
)} )}
+12 -109
View File
@@ -78,17 +78,19 @@ import {
} from "@/shadcn-bridge/heroui/dropdown"; } from "@/shadcn-bridge/heroui/dropdown";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Progress } from "@/shadcn-bridge/heroui/progress"; import { Progress } from "@/shadcn-bridge/heroui/progress";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
interface MonitorViewProps { interface MonitorViewProps {
nodeMap: Map< nodeMap: Map<
number, number,
{ id: number; name: string; connectionStatus: string; version?: string } { id: number; name: string; connectionStatus: string; version?: string }
>; >;
realtimeNodeMetrics: Record<number, RealtimeNodeMetric>;
wsConnected: boolean;
wsConnecting: boolean;
viewMode?: "list" | "grid"; viewMode?: "list" | "grid";
} }
type RealtimeNodeMetric = { export type RealtimeNodeMetric = {
receivedAt: number; receivedAt: number;
cpuUsage: number; cpuUsage: number;
memoryUsage: number; memoryUsage: number;
@@ -532,7 +534,13 @@ const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
maxTimeoutSec: 60, maxTimeoutSec: 60,
}; };
export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { export function MonitorView({
nodeMap,
realtimeNodeMetrics,
wsConnected,
wsConnecting,
viewMode = "grid",
}: MonitorViewProps) {
const [detailNodeId, setDetailNodeId] = useState<number | null>(null); const [detailNodeId, setDetailNodeId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [metrics, setMetrics] = useState<NodeMetricApiItem[]>([]); const [metrics, setMetrics] = useState<NodeMetricApiItem[]>([]);
@@ -582,27 +590,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const [resultsLimit, setResultsLimit] = useState(50); const [resultsLimit, setResultsLimit] = useState(50);
const [resultsLoading, setResultsLoading] = useState(false); const [resultsLoading, setResultsLoading] = useState(false);
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState< const nodes = useMemo(() => Array.from(nodeMap.values()), [nodeMap]);
Record<number, "online" | "offline">
>({});
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<
Record<number, RealtimeNodeMetric>
>({});
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 onlineNodes = nodes.filter((n) => n.connectionStatus === "online");
const preferredNodeId = onlineNodes[0]?.id ?? nodes[0]?.id ?? 0; const preferredNodeId = onlineNodes[0]?.id ?? nodes[0]?.id ?? 0;
@@ -610,91 +598,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const resolvedServiceMonitorLimits = const resolvedServiceMonitorLimits =
serviceMonitorLimits ?? DEFAULT_SERVICE_MONITOR_LIMITS; 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<string, unknown>;
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(() => { useEffect(() => {
if (!selectedNodeId && preferredNodeId > 0) { if (!selectedNodeId && preferredNodeId > 0) {
setSelectedNodeId(preferredNodeId); setSelectedNodeId(preferredNodeId);