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);