Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions frontend/src/hooks/useClusterStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClusterSummary | null>(null);
Expand All @@ -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;
Expand All @@ -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 = () => {
Expand All @@ -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); },
Expand All @@ -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' }));
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/state/clusterSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion frontend/tests/clusterSummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
});