From 7b412e750ba6df4e22911e3052cde0aa833a3233 Mon Sep 17 00:00:00 2001 From: "Patrick W. Healy" Date: Wed, 16 Sep 2026 21:51:40 +0000 Subject: [PATCH] frontend: verify summary-only reconnect and stale stream handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2243398-6c36-4c3d-969e-7ed7bfb5b459 --- frontend/src/hooks/useClusterStatus.ts | 25 ++++++++++++------------- frontend/src/state/clusterSummary.ts | 16 ++++++++++++++++ frontend/tests/clusterSummary.test.ts | 22 +++++++++++++++++++++- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/frontend/src/hooks/useClusterStatus.ts b/frontend/src/hooks/useClusterStatus.ts index b288a03ba..b96d9e868 100644 --- a/frontend/src/hooks/useClusterStatus.ts +++ b/frontend/src/hooks/useClusterStatus.ts @@ -4,8 +4,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { connectWebSocket, fetchClusterStatus } from '../api'; import type { StatusEvent } from '../api'; -import type { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta } from '../types'; -import { mergeLegacySummary, mergeSummary, toClusterSummary } from '../state/clusterSummary'; +import type { ClusterSummary } from '../types'; +import { summarizeEvent, summarySubscriptionMessage, toClusterSummary } from '../state/clusterSummary'; function useClusterStatus() { const [summary, setSummary] = useState(null); @@ -22,9 +22,10 @@ function useClusterStatus() { let keepalive: number | undefined; let revision = 0; let fetching = false; + let resyncExpected = true; const abort = new AbortController(); const update = (next: ClusterSummary | null) => { - if (disposed || !next) return; + if (disposed || !next || next === summaryRef.current) return; // Projection occurs before React's update queue: no queued callback holds // a legacy full response, even briefly across subsequent renders. summaryRef.current = next; @@ -41,7 +42,7 @@ function useClusterStatus() { const data = await fetchClusterStatus(abort.signal); if (!disposed && startedAtRevision === revision) update(toClusterSummary(data)); } catch (err) { - if (!disposed) { setError((err as Error).message); setLoading(false); } + if (!disposed && startedAtRevision === revision) { setError((err as Error).message); setLoading(false); } } finally { fetching = false; } }; const stopPoll = () => { @@ -55,19 +56,17 @@ function useClusterStatus() { }; const handleMessage = (event: StatusEvent) => { if (disposed) return; - if (event.type === 'cluster_summary' || event.type === 'cluster_status') { - update(toClusterSummary(event.data as ClusterSummary | ClusterStatus)); - } else if (event.type === 'cluster_summary_delta') { - if (!summaryRef.current) { void refresh(); return; } - update(mergeSummary(summaryRef.current, event.data as ClusterSummaryDelta)); - } else if (event.type === 'cluster_status_delta') { - if (!summaryRef.current) { void refresh(); return; } - update(mergeLegacySummary(summaryRef.current, event.data as ClusterStatusDelta)); + if (!summaryRef.current && (event.type === 'cluster_summary_delta' || event.type === 'cluster_status_delta')) { + void refresh(); + return; } + update(summarizeEvent(summaryRef.current, event, resyncExpected)); + if (event.type === 'cluster_summary' || event.type === 'cluster_status') resyncExpected = false; // Unsolicited legacy node details are deliberately ignored. }; const connect = () => { if (disposed) return; + resyncExpected = true; let lastMessageTime = Date.now(); const ws = connectWebSocket( (event) => { lastMessageTime = Date.now(); handleMessage(event); }, @@ -77,7 +76,7 @@ function useClusterStatus() { setError(null); stopPoll(); lastMessageTime = Date.now(); - ws?.send(JSON.stringify({ type: 'cluster_summary_subscribe' })); + ws?.send(JSON.stringify(summarySubscriptionMessage())); keepalive = window.setInterval(() => { if (Date.now() - lastMessageTime > 60000) { ws?.close(); return; } if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' })); diff --git a/frontend/src/state/clusterSummary.ts b/frontend/src/state/clusterSummary.ts index 5b46c9eac..fa2e65dbc 100644 --- a/frontend/src/state/clusterSummary.ts +++ b/frontend/src/state/clusterSummary.ts @@ -2,6 +2,22 @@ // SPDX-License-Identifier: Apache-2.0 import type { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta, NodeStatus, NodeSummary } from '../types'; +import type { StatusEvent } from '../api'; + +export function summarySubscriptionMessage() { + return { type: 'cluster_summary_subscribe' }; +} + +export function summarizeEvent(current: ClusterSummary | null, event: StatusEvent, resync = false): ClusterSummary | null { + if (event.type === 'cluster_status' || event.type === 'cluster_summary') { + const next = toClusterSummary(event.data as ClusterStatus | ClusterSummary); + if (!resync && current?.seq != null && next.seq != null && next.seq < current.seq) return current; + return next; + } + if (event.type === 'cluster_summary_delta') return mergeSummary(current, event.data as ClusterSummaryDelta); + if (event.type === 'cluster_status_delta') return mergeLegacySummary(current, event.data as ClusterStatusDelta); + return current; +} export function isSummaryOnline(node: NodeSummary): boolean { // Older summary servers lacked interface metadata; retain their established diff --git a/frontend/tests/clusterSummary.test.ts b/frontend/tests/clusterSummary.test.ts index 62fe3d703..dadb8280f 100644 --- a/frontend/tests/clusterSummary.test.ts +++ b/frontend/tests/clusterSummary.test.ts @@ -3,7 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { isSummaryOnline, mergeLegacySummary, mergeSummary, summarizeNode, toClusterSummary } from '../src/state/clusterSummary.ts'; +import { isSummaryOnline, mergeLegacySummary, mergeSummary, summarizeEvent, summarizeNode, summarySubscriptionMessage, toClusterSummary } from '../src/state/clusterSummary.ts'; test('full compatibility projection preserves counts and drops detail arrays', () => { const node = { @@ -86,3 +86,23 @@ test('legacy partial updates keep counts and CNI facts without retaining full ba const empty = mergeLegacySummary(next, { nodes: [] }); assert.deepEqual(empty.nodeSummaries, []); }); + +test('reconnect subscribes to summaries only; unsolicited detail updates cannot populate cluster state', () => { + for (let reconnect = 0; reconnect < 3; reconnect++) { + assert.deepEqual(summarySubscriptionMessage(), { type: 'cluster_summary_subscribe' }); + } + const initial = toClusterSummary({ seq: 4, nodeSummaries: [{ name: 'node', peerCount: 10 }] }); + for (const type of ['node_detail_response', 'node_detail_update'] as const) { + assert.equal(summarizeEvent(initial, { type, nodeName: 'node', data: { + nodeInfo: { name: 'node' }, peers: [{ name: 'hidden' }], bpfEntries: [{ cidr: 'hidden' }], + } }), initial); + } + const stale = { type: 'cluster_summary' as const, data: { seq: 1, nodeSummaries: [] } }; + assert.equal(summarizeEvent(initial, stale), initial); + assert.equal(summarizeEvent(initial, stale, true).seq, 1, 'new leader resync may reset sequence'); + const full = summarizeEvent(initial, { type: 'cluster_status', data: { + nodes: [{ nodeInfo: { name: 'node' }, peers: [{ name: 'hidden' }] }], + } }); + assert.equal(full.nodeSummaries[0].peerCount, 1); + assert.equal(JSON.stringify(full).includes('hidden'), false); +});