diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 000000000..763fa4253
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,52 @@
+# Dashboard status data
+
+The overview, node table and resource views use `ClusterSummary`. Older
+controllers can still return `ClusterStatus`; the browser immediately projects
+that input to summaries without retaining peer, route, next-hop or BPF arrays.
+The global **Cluster Status JSON** dialog also exports only this projection.
+
+Selecting a node does not request diagnostics. **Load data** reuses an unexpired
+browser snapshot or asks the controller for its cache/current request.
+**Refresh** requests a fresh collection. Neither summary updates, reconnection,
+opening a dialog nor expiry initiates collection.
+
+The node dialog distinguishes not-loaded, loading, loaded, expired and error.
+Peer/route/BPF tables and full node JSON are available only with valid details.
+A failed Refresh can display the previous still-valid snapshot, labeled with
+the request error, collection age, receipt time and expiry. At expiry the cache
+drops its payload and the heavy table component unmounts. Reads check expiry
+again; reads and summary updates never extend it. Browser caching uses the
+controller's TTL without additional entry-count limits. A burst of explicit
+loads can therefore retain many snapshots until their individual expirations.
+
+## Detail API
+
+Requests use the existing same-origin viewer authentication:
+
+- `POST /status/node/{name}/details`, body `{"forceRefresh":false}` (Load data)
+ or `{"forceRefresh":true}` (Refresh).
+- `GET /status/node/{name}/details?requestId={id}` while pending.
+- Results contain `state`, `nodeName`, optional `requestId`, `deadline`, `error`,
+ and `details`. States are `pending` (202), `complete` (200), `expired` (410),
+ `unavailable` (404), or `retryable` (503).
+- `details` contains `nodeName`, `requestId`, `collectedAt`, `receivedAt`,
+ `expiresAt`, and `status` (the full node payload).
+
+Pending requests poll at most once per second until the original controller
+deadline. Selection changes cancel obsolete browser waiters; unmount cancels
+all requests and timers. Errors require an explicit retry, not auto-loading.
+Old controllers without this API still provide an overview, but explicit
+detail loads report an error rather than falling back to persistent subscriptions.
+
+## Validation
+
+```sh
+npm --prefix frontend run build
+frontend/node_modules/.bin/tsc -p frontend/tsconfig.json
+node --test frontend/tests/*.test.ts
+```
+
+The tests require Node's built-in TypeScript stripping; no frontend test
+framework is required. Tests cover
+summary compatibility, observed count semantics, API mapping, explicit loads,
+cache reuse, refresh failures, cancellation, fixed deadlines and expiry.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c00b23cf0..55617d123 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -13,17 +13,13 @@ import {
import useClusterStatus from './hooks/useClusterStatus';
import useDashboardData from './hooks/useDashboardData';
import useNodeDetails from './hooks/useNodeDetails';
-import type { NodeStatus } from './types';
const NodeDetailModal = React.lazy(() => import('./components/nodes/NodeDetailDialog'));
-const noFullNodes: NodeStatus[] = [];
-const noLegacyDetail = () => undefined;
export default function App() {
const {
summary, loading, error, wsConnected, sendWsMessage
} = useClusterStatus();
- const nodes = noFullNodes;
const sites = summary?.sites || [];
const gatewayPools = summary?.gatewayPools || [];
const nodeSummaries = summary?.nodeSummaries || [];
@@ -120,14 +116,14 @@ export default function App() {
items.push(`controller: ${msg}`);
}
// Summary errors stay independent of explicitly loaded diagnostics.
- for (const ns of nodeSummaries) {
- const count = ns.errorCount || 0;
- if (count === 1 && ns.firstError) {
- items.push(`node ${ns.name || 'unknown'}: ${ns.firstError}`);
- } else if (count > 0) {
- items.push(`node ${ns.name || 'unknown'}: ${count} error(s)`);
- }
+ for (const ns of nodeSummaries) {
+ const count = ns.errorCount || 0;
+ if (count === 1 && ns.firstError) {
+ items.push(`node ${ns.name || 'unknown'}: ${ns.firstError}`);
+ } else if (count > 0) {
+ items.push(`node ${ns.name || 'unknown'}: ${count} error(s)`);
}
+ }
items.sort();
return items;
}, [summary?.warnings, nodeSummaries]);
@@ -201,36 +197,28 @@ export default function App() {
visibleNodeSummaries
} = useDashboardData({
summary,
- status: null,
- nodes,
nodeSummaries,
gatewayPools,
gatewayPoolHiddenNames: hiddenGatewayPools,
hiddenSites,
selectedNodeTypesFilter,
- pullEnabledOptimistic,
- selectedNodeName,
- nodeDetail: noLegacyDetail
+ pullEnabledOptimistic
});
// All known node names for the detail modal's peer navigation
const allNodeNames = useMemo(() => {
const names = nodeSummaries.map((ns) => ns.name || '').filter(Boolean);
- if (names.length === 0) {
- return nodes.map((n) => n.nodeInfo?.name || '').filter(Boolean);
- }
return names;
- }, [nodeSummaries, nodes]);
+ }, [nodeSummaries]);
useEffect(() => {
if (!selectedNodeName) return;
// Check if node still exists in the cluster
- const exists = nodeSummaries.some((ns) => ns.name === selectedNodeName)
- || nodes.some((n) => n.nodeInfo?.name === selectedNodeName);
+ const exists = nodeSummaries.some((ns) => ns.name === selectedNodeName);
if (!exists) {
setSelectedNodeName(null);
}
- }, [nodeSummaries, nodes, selectedNodeName]);
+ }, [nodeSummaries, selectedNodeName]);
const wsState = wsConnected ? 'ok' : summary ? 'warn' : 'err';
const wsLabel = wsConnected
@@ -489,7 +477,6 @@ export default function App() {
sites={sites}
siteCounts={siteCounts}
nodeSummaries={nodeSummaries}
- nodes={nodes}
/>
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 199070df0..ab7904cea 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -25,7 +25,11 @@ async function fetchNodeDetails(path: string, options: RequestInit): Promise = {};
- for (const node of current.nodes || []) {
- const name = node.nodeInfo?.name;
- if (name) {
- nodeMap[name] = node;
- }
- }
-
- for (const name of delta.removedNodes || []) {
- delete nodeMap[name];
- }
-
- for (const updated of delta.updatedNodes || []) {
- const name = updated.nodeInfo?.name;
- if (!name) continue;
- if (nodeMap[name]) {
- nodeMap[name] = { ...nodeMap[name], ...updated };
- } else {
- nodeMap[name] = updated;
- }
- }
-
- merged.nodes = Object.values(nodeMap);
- }
-
- return merged;
-}
-
export function connectWebSocket(
onMessage: (event: StatusEvent) => void,
onOpen: () => void,
diff --git a/frontend/src/components/network/SitesCard.tsx b/frontend/src/components/network/SitesCard.tsx
index d1166803c..8983c5337 100644
--- a/frontend/src/components/network/SitesCard.tsx
+++ b/frontend/src/components/network/SitesCard.tsx
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { useMemo, useState } from 'react';
-import { NodeStatus, NodeSummary, SiteStatus } from '../../types';
+import { NodeSummary, SiteStatus } from '../../types';
type SummaryRow = {
id: string;
@@ -19,13 +19,11 @@ type SortKey = 'name' | 'workers' | 'gateways' | 'state';
function SitesCard({
sites,
siteCounts,
- nodeSummaries,
- nodes
+ nodeSummaries
}: {
sites: SiteStatus[];
siteCounts: Map;
nodeSummaries: NodeSummary[];
- nodes: NodeStatus[];
}) {
const [sort, setSort] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({ key: 'name', direction: 'asc' });
@@ -36,23 +34,12 @@ function SitesCard({
let workers = 0;
let gateways = 0;
- if (nodeSummaries.length > 0) {
- for (const ns of nodeSummaries) {
- if ((ns.siteName || '-') !== name) continue;
- if (ns.isGateway) {
- gateways++;
- } else {
- workers++;
- }
- }
- } else {
- for (const node of nodes) {
- if ((node.nodeInfo?.siteName || '-') !== name) continue;
- if (node.nodeInfo?.isGateway) {
- gateways++;
- } else {
- workers++;
- }
+ for (const ns of nodeSummaries) {
+ if ((ns.siteName || '-') !== name) continue;
+ if (ns.isGateway) {
+ gateways++;
+ } else {
+ workers++;
}
}
@@ -66,7 +53,7 @@ function SitesCard({
state: counts.total === 0 ? 'No Data' : counts.online === counts.total ? 'Healthy' : 'Unhealthy'
};
});
- }, [nodeSummaries, nodes, siteCounts, sites]);
+ }, [nodeSummaries, siteCounts, sites]);
const sortedRows = useMemo(() => {
const direction = sort.direction === 'asc' ? 1 : -1;
diff --git a/frontend/src/components/status/StatusJsonModal.tsx b/frontend/src/components/status/StatusJsonModal.tsx
index 408960846..99342a307 100644
--- a/frontend/src/components/status/StatusJsonModal.tsx
+++ b/frontend/src/components/status/StatusJsonModal.tsx
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// SPDX-License-Identifier: Apache-2.0
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { ClusterSummary } from '../../types';
import { fetchClusterStatus } from '../../api';
import { toClusterSummary } from '../../state/clusterSummary';
@@ -32,10 +32,11 @@ function StatusJsonModal({
useEffect(() => {
let cancelled = false;
+ const abort = new AbortController();
if (open) {
setFetching(true);
setFetchError(null);
- fetchClusterStatus()
+ fetchClusterStatus(abort.signal)
.then((data) => {
if (cancelled) return;
setSnapshotStatus(toClusterSummary(data));
@@ -52,7 +53,7 @@ function StatusJsonModal({
} else {
setSnapshotStatus(null);
}
- return () => { cancelled = true; };
+ return () => { cancelled = true; abort.abort(); };
}, [open]);
const statusJsonValue = useMemo(() => {
diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts
index 1e4fde281..2b8c393a1 100644
--- a/frontend/src/hooks/useDashboardData.ts
+++ b/frontend/src/hooks/useDashboardData.ts
@@ -2,258 +2,89 @@
// SPDX-License-Identifier: Apache-2.0
import { useMemo } from 'react';
-import { ClusterStatus, ClusterSummary, GatewayPoolStatus, NodeStatus, NodeSummary } from '../types';
-import { getGatewayPoolNodeNames, isPeerOnline } from '../components/nodes/shared/index';
+import type { ClusterSummary, GatewayPoolStatus, NodeSummary } from '../types';
+import { getGatewayPoolNodeNames } from '../components/nodes/shared/index';
+import { isSummaryOnline } from '../state/clusterSummary';
type DashboardDataParams = {
summary: ClusterSummary | null;
- status: ClusterStatus | null;
- nodes: NodeStatus[];
nodeSummaries: NodeSummary[];
gatewayPools: GatewayPoolStatus[];
gatewayPoolHiddenNames: Set;
hiddenSites: Set;
selectedNodeTypesFilter: Set;
pullEnabledOptimistic: boolean | null;
- selectedNodeName: string | null;
- nodeDetail: (name: string) => NodeStatus | undefined;
};
function useDashboardData({
- summary,
- status,
- nodes,
- nodeSummaries,
- gatewayPools,
- gatewayPoolHiddenNames,
- hiddenSites,
- selectedNodeTypesFilter,
- pullEnabledOptimistic,
- selectedNodeName,
- nodeDetail
+ summary, nodeSummaries, gatewayPools, gatewayPoolHiddenNames,
+ hiddenSites, selectedNodeTypesFilter, pullEnabledOptimistic,
}: DashboardDataParams) {
- const gatewayNodeNames = useMemo(() => {
- const names = new Set();
- for (const pool of gatewayPools) {
- for (const gateway of getGatewayPoolNodeNames(pool)) {
- names.add(gateway);
- }
- }
- return names;
- }, [gatewayPools]);
-
const gatewayByNode = useMemo(() => {
const map = new Map();
for (const pool of gatewayPools) {
if (!pool.name) continue;
- for (const gateway of getGatewayPoolNodeNames(pool)) {
- map.set(gateway, pool.name);
- }
+ for (const gateway of getGatewayPoolNodeNames(pool)) map.set(gateway, pool.name);
}
return map;
}, [gatewayPools]);
const nodeK8sStatusMap = useMemo(() => {
const map = new Map();
- for (const ns of nodeSummaries) {
- if (ns.name && ns.k8sReady) {
- map.set(ns.name, ns.k8sReady);
- }
- }
- for (const n of nodes) {
- const name = n.nodeInfo?.name;
- const ready = n.nodeInfo?.k8sReady;
- if (name && ready && !map.has(name)) {
- map.set(name, ready);
- }
+ for (const node of nodeSummaries) {
+ if (node.name && node.k8sReady) map.set(node.name, node.k8sReady);
}
return map;
- }, [nodeSummaries, nodes]);
+ }, [nodeSummaries]);
- // Site counts from full nodes (backward compat) or from summary node summaries
const siteCounts = useMemo(() => {
const counts = new Map();
- if (nodes.length > 0) {
- for (const node of nodes) {
- const siteName = node.nodeInfo?.siteName;
- if (!siteName) continue;
- const nodeName = node.nodeInfo?.name;
- const isGatewayNode = node.nodeInfo?.isGateway || (nodeName ? gatewayNodeNames.has(nodeName) : false);
- if (isGatewayNode) continue;
- const current = counts.get(siteName) || { online: 0, total: 0 };
- current.total += 1;
- if (node.nodeInfo?.wireGuard?.interface) {
- current.online += 1;
- }
- counts.set(siteName, current);
- }
- } else {
- for (const ns of nodeSummaries) {
- const siteName = ns.siteName;
- if (!siteName) continue;
- const nodeName = ns.name || '';
- const isGatewayNode = ns.isGateway || gatewayNodeNames.has(nodeName);
- if (isGatewayNode) continue;
- const current = counts.get(siteName) || { online: 0, total: 0 };
- current.total += 1;
- if (ns.cniTone !== 'danger' && ns.cniStatus !== 'Unknown') {
- current.online += 1;
- }
- counts.set(siteName, current);
- }
+ for (const node of nodeSummaries) {
+ if (!node.siteName || node.isGateway || gatewayByNode.has(node.name || '')) continue;
+ const current = counts.get(node.siteName) || { online: 0, total: 0 };
+ current.total++;
+ if (isSummaryOnline(node)) current.online++;
+ counts.set(node.siteName, current);
}
return counts;
- }, [nodes, nodeSummaries, gatewayNodeNames]);
+ }, [nodeSummaries, gatewayByNode]);
const poolCounts = useMemo(() => {
const counts = new Map();
- if (nodes.length > 0) {
- const nodeByName = new Map();
- for (const node of nodes) {
- const name = node.nodeInfo?.name;
- if (!name) continue;
- nodeByName.set(name, node);
- }
- for (const pool of gatewayPools) {
- const poolName = pool.name || '';
- if (!poolName) continue;
- const gwNames = getGatewayPoolNodeNames(pool);
- let online = 0;
- for (const gwName of gwNames) {
- const node = nodeByName.get(gwName);
- if (!node) continue;
- if (node.nodeInfo?.wireGuard?.interface) {
- online += 1;
- }
- }
- const expectedTotal = Math.max(
- typeof pool.nodeCount === 'number' ? pool.nodeCount : 0,
- gwNames.length,
- );
- counts.set(poolName, { online, total: expectedTotal });
- }
- } else {
- const nsByName = new Map();
- for (const ns of nodeSummaries) {
- if (ns.name) nsByName.set(ns.name, ns);
- }
- for (const pool of gatewayPools) {
- const poolName = pool.name || '';
- if (!poolName) continue;
- const gwNames = getGatewayPoolNodeNames(pool);
- let online = 0;
- for (const gwName of gwNames) {
- const ns = nsByName.get(gwName);
- if (!ns) continue;
- if (ns.cniTone !== 'danger' && ns.cniStatus !== 'Unknown') {
- online += 1;
- }
- }
- const expectedTotal = Math.max(
- typeof pool.nodeCount === 'number' ? pool.nodeCount : 0,
- gwNames.length,
- );
- counts.set(poolName, { online, total: expectedTotal });
- }
+ const byName = new Map(nodeSummaries.map((node) => [node.name, node]));
+ for (const pool of gatewayPools) {
+ if (!pool.name) continue;
+ const names = getGatewayPoolNodeNames(pool);
+ const online = names.filter((name) => {
+ const node = byName.get(name);
+ return node && isSummaryOnline(node);
+ }).length;
+ counts.set(pool.name, { online, total: Math.max(pool.nodeCount || 0, names.length) });
}
return counts;
- }, [gatewayPools, nodes, nodeSummaries]);
-
- // Visible full nodes provide backward compatibility for summary filtering.
- const visibleNodes = useMemo(() => {
- return nodes.filter((node) => {
- const nodeName = node.nodeInfo?.name || '';
- const isGatewayNode = node.nodeInfo?.isGateway || gatewayByNode.has(nodeName);
- const nodeType = isGatewayNode ? 'Gateway' : 'Worker';
- if (!selectedNodeTypesFilter.has(nodeType)) {
- return false;
- }
- const poolName = gatewayByNode.get(nodeName);
- if (poolName) {
- return !gatewayPoolHiddenNames.has(poolName);
- }
- const siteName = node.nodeInfo?.siteName;
- if (!siteName) return true;
- return !hiddenSites.has(siteName);
- });
- }, [nodes, gatewayByNode, selectedNodeTypesFilter, gatewayPoolHiddenNames, hiddenSites]);
-
- // Visible node summaries (for NodesTable)
- const visibleNodeSummaries = useMemo(() => {
- return nodeSummaries.filter((ns) => {
- const nodeName = ns.name || '';
- const isGatewayNode = ns.isGateway || gatewayByNode.has(nodeName);
- const nodeType = isGatewayNode ? 'Gateway' : 'Worker';
- if (!selectedNodeTypesFilter.has(nodeType)) {
- return false;
- }
- const poolName = gatewayByNode.get(nodeName);
- if (poolName) {
- return !gatewayPoolHiddenNames.has(poolName);
- }
- const siteName = ns.siteName;
- if (!siteName) return true;
- return !hiddenSites.has(siteName);
- });
- }, [nodeSummaries, gatewayByNode, selectedNodeTypesFilter, gatewayPoolHiddenNames, hiddenSites]);
-
- // Node healthy/total counts: prefer summary data
- const nodeHealthyCount = useMemo(() => {
- if (nodeSummaries.length > 0) {
- return nodeSummaries.filter((ns) => ns.cniTone === 'success').length;
- }
- return nodes.filter((node) => node.nodeInfo?.wireGuard?.interface).length;
- }, [nodes, nodeSummaries]);
-
- const nodeTotalCount = nodeSummaries.length || nodes.length || summary?.nodeCount || status?.nodeCount || 0;
-
- // Peer health: prefer summary aggregation
- const peerHealth = useMemo(() => {
- if (nodeSummaries.length > 0) {
- let healthy = 0;
- let total = 0;
- for (const ns of nodeSummaries) {
- total += ns.peerCount || 0;
- healthy += ns.healthyPeers || 0;
- }
- return { healthy, total };
- }
- let healthy = 0;
- let total = 0;
- for (const node of nodes) {
- for (const peer of node.peers || []) {
- total += 1;
- if (isPeerOnline(peer)) {
- healthy += 1;
- }
- }
- }
- return { healthy, total };
- }, [nodes, nodeSummaries]);
-
- const effectivePullEnabled = pullEnabledOptimistic ?? Boolean(summary?.pullEnabled ?? status?.pullEnabled);
-
- // Active selected node detail from the cache
- const activeSelectedNode = useMemo(() => {
- if (!selectedNodeName) return null;
- const detail = nodeDetail(selectedNodeName);
- if (detail) return detail;
- // Backward compat: check full nodes array
- return nodes.find((node) => node.nodeInfo?.name === selectedNodeName) || null;
- }, [selectedNodeName, nodeDetail, nodes]);
+ }, [gatewayPools, nodeSummaries]);
+
+ const visibleNodeSummaries = useMemo(() => nodeSummaries.filter((node) => {
+ const name = node.name || '';
+ const isGateway = node.isGateway || gatewayByNode.has(name);
+ if (!selectedNodeTypesFilter.has(isGateway ? 'Gateway' : 'Worker')) return false;
+ const pool = gatewayByNode.get(name);
+ if (pool) return !gatewayPoolHiddenNames.has(pool);
+ return !node.siteName || !hiddenSites.has(node.siteName);
+ }), [nodeSummaries, gatewayByNode, selectedNodeTypesFilter, gatewayPoolHiddenNames, hiddenSites]);
+
+ const nodeHealthyCount = useMemo(() =>
+ nodeSummaries.filter((node) => node.cniTone === 'success').length, [nodeSummaries]);
+ const peerHealth = useMemo(() => nodeSummaries.reduce((counts, node) => ({
+ healthy: counts.healthy + (node.healthyPeers || 0),
+ total: counts.total + (node.peerCount || 0),
+ }), { healthy: 0, total: 0 }), [nodeSummaries]);
return {
- activeSelectedNode,
- effectivePullEnabled,
- gatewayByNode,
- nodeK8sStatusMap,
- nodeHealthyCount,
- nodeTotalCount,
- peerHealth,
- poolCounts,
- siteCounts,
- visibleNodes,
- visibleNodeSummaries
+ effectivePullEnabled: pullEnabledOptimistic ?? Boolean(summary?.pullEnabled),
+ gatewayByNode, nodeK8sStatusMap, nodeHealthyCount,
+ nodeTotalCount: nodeSummaries.length || summary?.nodeCount || 0,
+ peerHealth, poolCounts, siteCounts, visibleNodeSummaries,
};
}
diff --git a/frontend/src/state/clusterSummary.ts b/frontend/src/state/clusterSummary.ts
index 78fcf58d7..5b46c9eac 100644
--- a/frontend/src/state/clusterSummary.ts
+++ b/frontend/src/state/clusterSummary.ts
@@ -3,6 +3,12 @@
import type { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta, NodeStatus, NodeSummary } from '../types';
+export function isSummaryOnline(node: NodeSummary): boolean {
+ // Older summary servers lacked interface metadata; retain their established
+ // fallback while full-response projection preserves actual interface counts.
+ return node.wireGuardOnline ?? (node.cniTone !== 'danger' && node.cniStatus !== 'Unknown');
+}
+
function cniState(source?: string, fetchError?: string, errorCount = 0, routeMismatch = false) {
const [cniStatus, cniTone] = source === 'no-data' ? ['No data', 'warning']
: fetchError ? ['Fetch error', 'danger']
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 551489cf1..46e796524 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -279,7 +279,7 @@ export type NodeDetailSnapshot = {
};
export type NodeDetailResult = {
- state: 'pending' | 'complete' | 'expired' | 'unavailable' | 'error';
+ state: 'pending' | 'complete' | 'expired' | 'unavailable' | 'retryable';
nodeName: string;
requestId?: string;
deadline?: string;
diff --git a/frontend/tests/api.test.ts b/frontend/tests/api.test.ts
index 3d04bf4c7..b11d110d1 100644
--- a/frontend/tests/api.test.ts
+++ b/frontend/tests/api.test.ts
@@ -27,7 +27,7 @@ test('detail API uses viewer credentials, escaped names and request IDs, and exp
assert.equal(calls[2].init.cache, 'no-store');
});
-test('auth, leadership, malformed and network failures are surfaced', async (t) => {
+test('auth, leadership and malformed failures are surfaced', async (t) => {
const responses = [
new Response('viewer authorization required', { status: 403 }),
new Response(JSON.stringify({ error: 'leadership changed' }), { status: 503 }),
@@ -40,6 +40,23 @@ test('auth, leadership, malformed and network failures are surfaced', async (t)
await assert.rejects(requestNodeDetails('node', false, signal), /Invalid detail response/);
});
+test('HTTP lifecycle failures preserve expired, unavailable and retryable states', async (t) => {
+ const statuses = { expired: 410, unavailable: 404, retryable: 503 };
+ for (const [state, status] of Object.entries(statuses)) {
+ t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({
+ state, nodeName: 'node', requestId: 'a', error: 'explicit failure',
+ }), { status }));
+ const result = await pollNodeDetails('node', 'a', new AbortController().signal);
+ assert.equal(result.state, state);
+ assert.equal(result.error, 'explicit failure');
+ }
+});
+
+test('network rejection propagates without success-shaped data', async (t) => {
+ t.mock.method(globalThis, 'fetch', async () => { throw new TypeError('network unavailable'); });
+ await assert.rejects(requestNodeDetails('node', false, new AbortController().signal), /network unavailable/);
+});
+
test('bulk polling accepts summary and legacy shapes and passes cancellation', async (t) => {
const responses = [{ nodeSummaries: [{ name: 'node' }] }, { nodes: [{ nodeInfo: { name: 'node' } }] }];
const signal = new AbortController().signal;
diff --git a/frontend/tests/clusterSummary.test.ts b/frontend/tests/clusterSummary.test.ts
index 53d5bfb33..62fe3d703 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 { mergeLegacySummary, mergeSummary, summarizeNode, toClusterSummary } from '../src/state/clusterSummary.ts';
+import { isSummaryOnline, mergeLegacySummary, mergeSummary, summarizeNode, toClusterSummary } from '../src/state/clusterSummary.ts';
test('full compatibility projection preserves counts and drops detail arrays', () => {
const node = {
@@ -55,6 +55,15 @@ test('CNI priority preserves no-data, errors, unknown and health', () => {
assert.equal(summarizeNode({ statusSource: 'push' }).cniTone, 'success');
});
+test('resource online counts retain interface semantics independently of CNI health', () => {
+ assert.equal(isSummaryOnline(summarizeNode({
+ nodeInfo: { wireGuard: { interface: 'wg0' } }, nodeErrors: [{ message: 'broken route' }],
+ })), true);
+ assert.equal(isSummaryOnline(summarizeNode({ statusSource: 'push' })), false);
+ assert.equal(isSummaryOnline({ cniStatus: 'Unknown', cniTone: 'warning' }), false);
+ assert.equal(isSummaryOnline({ cniStatus: 'Healthy', cniTone: 'success' }), true);
+});
+
test('summary deltas reject stale sequence and remove nodes without losing resources', () => {
const initial = toClusterSummary({ seq: 4, sites: [{ name: 'site' }], nodeSummaries: [{ name: 'old' }] });
assert.equal(mergeSummary(initial, { seq: 4, removedNodes: ['old'] }), initial);