diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4036872df..60eb369d7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // SPDX-License-Identifier: Apache-2.0 -import { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta, NodeStatus } from './types'; +import { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta, NodeStatus, NodeDetailResult } from './types'; export type StatusEvent = { type: 'cluster_status' | 'cluster_status_delta' | 'cluster_summary' | 'cluster_summary_delta' | 'node_detail_response' | 'node_detail_update'; @@ -15,6 +15,37 @@ function buildControllerUrl(path: string): string { return path; } +async function fetchNodeDetails(path: string, options: RequestInit): Promise { + const response = await fetch(buildControllerUrl(path), { credentials: 'same-origin', ...options }); + const text = await response.text(); + let result: NodeDetailResult; + try { + result = JSON.parse(text); + } catch { + throw new Error(`Detail request failed (${response.status} ${response.statusText})${text ? `: ${text}` : ''}`); + } + if (!response.ok) { + throw new Error(result?.error || `Detail request failed (${response.status} ${response.statusText})`); + } + if (!result || typeof result.state !== 'string') { + throw new Error('Invalid detail response from controller'); + } + return result; +} + +export function requestNodeDetails(name: string, forceRefresh: boolean, signal: AbortSignal) { + return fetchNodeDetails(`/status/node/${encodeURIComponent(name)}/details`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ forceRefresh }), signal, + }); +} + +export function pollNodeDetails(name: string, requestId: string, signal: AbortSignal) { + return fetchNodeDetails(`/status/node/${encodeURIComponent(name)}/details?requestId=${encodeURIComponent(requestId)}`, { + signal, cache: 'no-store', + }); +} + export async function fetchClusterStatus(): Promise { const url = buildControllerUrl('/status/json'); try { diff --git a/frontend/src/hooks/useNodeDetails.ts b/frontend/src/hooks/useNodeDetails.ts new file mode 100644 index 000000000..4df04dedf --- /dev/null +++ b/frontend/src/hooks/useNodeDetails.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { pollNodeDetails, requestNodeDetails } from '../api'; +import { NodeDetails } from '../state/nodeDetails'; + +export default function useNodeDetails(selectedNodeName: string | null) { + const [, setVersion] = useState(0); + const storeRef = useRef(null); + if (!storeRef.current) { + storeRef.current = new NodeDetails( + { request: requestNodeDetails, poll: pollNodeDetails }, + () => setVersion((version) => version + 1) + ); + } + const store = storeRef.current; + useEffect(() => () => store.dispose(), [store]); + // Selection only cancels obsolete waiters. It never initiates collection. + useEffect(() => () => { + if (selectedNodeName) store.cancel(selectedNodeName); + }, [store, selectedNodeName]); + const load = useCallback((forceRefresh = false) => { + if (selectedNodeName) store.load(selectedNodeName, forceRefresh); + }, [store, selectedNodeName]); + const cancel = useCallback(() => { + if (selectedNodeName) store.cancel(selectedNodeName); + }, [store, selectedNodeName]); + return { + detail: selectedNodeName ? store.read(selectedNodeName) : { state: 'not-loaded' as const }, + load, cancel, + }; +} diff --git a/frontend/src/state/clusterSummary.ts b/frontend/src/state/clusterSummary.ts new file mode 100644 index 000000000..a96de5dcd --- /dev/null +++ b/frontend/src/state/clusterSummary.ts @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +import type { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta, NodeInfo, 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 + // 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'] + : errorCount ? ['Errors', 'danger'] + : routeMismatch ? ['Route mismatch', 'warning'] + : source === 'stale' || source === 'error' ? ['Stale', 'warning'] + : !source ? ['Unknown', 'warning'] : ['Healthy', 'success']; + return { cniStatus, cniTone }; +} + +export function summarizeNode(node: NodeStatus, now = Date.now()): NodeSummary { + const peers = node.peers || []; + const healthyPeers = peers.filter((peer) => peer.healthCheck?.enabled + ? peer.healthCheck.status?.toLowerCase() === 'up' + : Boolean(peer.tunnel?.lastHandshake && now - Date.parse(peer.tunnel.lastHandshake) < 180000)).length; + const routeMismatch = (node.routingTable?.routes || []).some((route) => + (route.nextHops || []).some((hop) => (hop.expected === true) !== (hop.present === true))); + const errorCount = node.nodeErrors?.length || 0; + const source = node.statusSource; + return { + nodeInfo: summaryNodeInfo(node.nodeInfo), + lastPushTime: node.lastPushTime, + name: node.nodeInfo?.name, + siteName: node.nodeInfo?.siteName, + isGateway: node.nodeInfo?.isGateway, + k8sReady: node.nodeInfo?.k8sReady, + statusSource: source, + ...cniState(source, node.fetchError, errorCount, routeMismatch), errorCount, + firstError: node.nodeErrors?.[0]?.message, + peerCount: peers.length, + healthyPeers, + routeCount: node.routingTable?.routes?.length || 0, + routeMismatch, + fetchError: node.fetchError, + wireGuardOnline: Boolean(node.nodeInfo?.wireGuard?.interface), + }; +} + +function summaryNodeInfo(info?: NodeInfo): NodeInfo | undefined { + if (!info) return undefined; + return { + name: info.name, siteName: info.siteName, isGateway: info.isGateway, + k8sReady: info.k8sReady, k8sUpdatedAt: info.k8sUpdatedAt, + podCIDRs: info.podCIDRs, internalIPs: info.internalIPs, externalIPs: info.externalIPs, + providerId: info.providerId, osImage: info.osImage, kernel: info.kernel, + kubelet: info.kubelet, arch: info.arch, nodeOs: info.nodeOs, k8sLabels: info.k8sLabels, + buildInfo: info.buildInfo && { + version: info.buildInfo.version, commit: info.buildInfo.commit, buildTime: info.buildInfo.buildTime, + }, + wireGuard: info.wireGuard && { + interface: info.wireGuard.interface, publicKey: info.wireGuard.publicKey, peerCount: info.wireGuard.peerCount, + }, + }; +} + +// Whitelist wire fields, including for already-summary input. Never spread full +// cluster/node objects into persistent state or the global JSON export. +function summaryNode(node: NodeSummary): NodeSummary { + return { + nodeInfo: summaryNodeInfo(node.nodeInfo), lastPushTime: node.lastPushTime, + name: node.name, siteName: node.siteName, isGateway: node.isGateway, + k8sReady: node.k8sReady, statusSource: node.statusSource, + cniStatus: node.cniStatus, cniTone: node.cniTone, + errorCount: node.errorCount, firstError: node.firstError, + peerCount: node.peerCount, healthyPeers: node.healthyPeers, + routeCount: node.routeCount, routeMismatch: node.routeMismatch, + fetchError: node.fetchError, wireGuardOnline: node.wireGuardOnline, + }; +} + +export function toClusterSummary(input: ClusterSummary | ClusterStatus, now = Date.now()): ClusterSummary { + const summary = input as ClusterSummary; + return { + seq: summary.seq, timestamp: input.timestamp, + nodeCount: input.nodeCount, siteCount: input.siteCount, + azureTenantId: input.azureTenantId, leaderInfo: input.leaderInfo, + buildInfo: input.buildInfo, sites: input.sites, + gatewayPools: input.gatewayPools, peerings: input.peerings, + errors: input.errors, warnings: input.warnings, problems: input.problems, + pullEnabled: input.pullEnabled, + nodeSummaries: summary.nodeSummaries != null + ? summary.nodeSummaries.map(summaryNode) + : ((input as ClusterStatus).nodes || []).map((node) => summarizeNode(node, now)), + }; +} + +export function mergeSummary( + current: ClusterSummary | null, delta: ClusterSummaryDelta +): ClusterSummary | null { + if (!current) return null; + if (current.seq != null && delta.seq != null && delta.seq <= current.seq) return current; + const metadata = toClusterSummary(delta); + const merged = { ...current }; + for (const key of Object.keys(metadata) as (keyof ClusterSummary)[]) { + if (key !== 'nodeSummaries' && metadata[key] !== undefined) { + Object.assign(merged, { [key]: metadata[key] }); + } + } + if (delta.nodeSummaries || delta.removedNodes) { + const nodes = new Map((current.nodeSummaries || []).map((node) => [node.name, node])); + for (const name of delta.removedNodes || []) nodes.delete(name); + for (const node of delta.nodeSummaries || []) nodes.set(node.name, summaryNode(node)); + merged.nodeSummaries = [...nodes.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')); + } + return merged; +} + +export function mergeLegacySummary(current: ClusterSummary | null, delta: ClusterStatusDelta): ClusterSummary | null { + if (delta.nodes) return toClusterSummary({ ...current, ...delta, nodeSummaries: undefined } as ClusterStatus); + // Legacy updatedNodes contains changed top-level fields. Preserve summary + // counts when a patch omits the corresponding full array. + const previous = new Map((current?.nodeSummaries || []).map((node) => [node.name, node])); + const nodeSummaries = (delta.updatedNodes || []).map((node) => { + const next = summarizeNode(node); + const old = previous.get(next.name); + if (!old) return next; + const merged = { + ...old, ...next, + nodeInfo: node.nodeInfo ? next.nodeInfo : old.nodeInfo, + lastPushTime: node.lastPushTime ?? old.lastPushTime, + siteName: node.nodeInfo ? next.siteName : old.siteName, + isGateway: node.nodeInfo ? next.isGateway : old.isGateway, + k8sReady: node.nodeInfo ? next.k8sReady : old.k8sReady, + wireGuardOnline: node.nodeInfo ? next.wireGuardOnline : old.wireGuardOnline, + peerCount: node.peers ? next.peerCount : old.peerCount, + healthyPeers: node.peers ? next.healthyPeers : old.healthyPeers, + routeCount: node.routingTable ? next.routeCount : old.routeCount, + routeMismatch: node.routingTable ? next.routeMismatch : old.routeMismatch, + errorCount: node.nodeErrors ? next.errorCount : old.errorCount, + firstError: node.nodeErrors ? next.firstError : old.firstError, + statusSource: node.statusSource ?? old.statusSource, + fetchError: node.fetchError ?? old.fetchError, + }; + return { ...merged, ...cniState(merged.statusSource, merged.fetchError, merged.errorCount, merged.routeMismatch) }; + }); + return mergeSummary(current, { ...toClusterSummary(delta), nodeSummaries, removedNodes: delta.removedNodes }); +} diff --git a/frontend/src/state/nodeDetails.ts b/frontend/src/state/nodeDetails.ts new file mode 100644 index 000000000..b0371cf27 --- /dev/null +++ b/frontend/src/state/nodeDetails.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +import type { NodeDetailResult, NodeDetailSnapshot } from '../types'; + +export type DetailView = { + state: 'not-loaded' | 'loading' | 'loaded' | 'expired' | 'error'; + snapshot?: NodeDetailSnapshot; + error?: string; + deadline?: string; +}; +type Timer = ReturnType; +type Operation = { abort: AbortController; deadline: number; requestId?: string; timer?: Timer }; +type Transport = { + request: (name: string, forceRefresh: boolean, signal: AbortSignal) => Promise; + poll: (name: string, requestId: string, signal: AbortSignal) => Promise; +}; +type Clock = { + now: () => number; + setTimeout: (callback: () => void, delay: number) => Timer; + clearTimeout: (timer: Timer) => void; +}; +const browserClock: Clock = { + now: Date.now, + // Browser timers require their global receiver, not the injected clock object. + setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay), + clearTimeout: (timer) => globalThis.clearTimeout(timer), +}; +const notLoaded: DetailView = { state: 'not-loaded' }; + +// Only this store owns retained snapshots. React subscribes to a version, not a +// second cache. Timers and async operations capture names/IDs, never old data. +export class NodeDetails { + private views = new Map(); + private operations = new Map(); + private expiryTimers = new Map(); + private transport: Transport; + private changed: () => void; + private clock: Clock; + + constructor(transport: Transport, changed: () => void, clock: Clock = browserClock) { + this.transport = transport; + this.changed = changed; + this.clock = clock; + } + + read(name: string): DetailView { + this.expire(name); + return this.views.get(name) || notLoaded; + } + + private expire(name: string) { + const view = this.views.get(name); + if (view?.snapshot && !(Date.parse(view.snapshot.expiresAt) > this.clock.now())) { + this.clearExpiry(name); + this.views.set(name, { + state: view.state === 'loading' ? 'loading' : view.error ? 'error' : 'expired', + error: view.error, deadline: view.deadline, + }); + } + } + + private clearExpiry(name: string) { + const timer = this.expiryTimers.get(name); + if (timer !== undefined) this.clock.clearTimeout(timer); + this.expiryTimers.delete(name); + } + + private scheduleExpiry(name: string) { + this.clearExpiry(name); + const snapshot = this.views.get(name)?.snapshot; + if (!snapshot) return; + const delay = Date.parse(snapshot.expiresAt) - this.clock.now(); + this.expiryTimers.set(name, this.clock.setTimeout(() => { + this.expire(name); + if (this.views.get(name)?.snapshot) this.scheduleExpiry(name); + this.changed(); + }, Math.min(Math.max(0, delay), 2147483647))); + } + + private publish(name: string, view: DetailView) { + this.views.set(name, view); + this.changed(); + } + + cancel(name: string) { + const op = this.operations.get(name); + if (!op) return; + this.operations.delete(name); + op.abort.abort(); + if (op.timer !== undefined) this.clock.clearTimeout(op.timer); + const view = this.read(name); + this.publish(name, { state: view.snapshot ? 'loaded' : 'not-loaded', snapshot: view.snapshot }); + } + + dispose() { + for (const op of this.operations.values()) { + op.abort.abort(); + if (op.timer !== undefined) this.clock.clearTimeout(op.timer); + } + for (const timer of this.expiryTimers.values()) this.clock.clearTimeout(timer); + this.operations.clear(); + this.expiryTimers.clear(); + this.views.clear(); + } + + load(name: string, forceRefresh = false) { + if (!name) return; + if (!forceRefresh && this.operations.has(name)) return; + const view = this.read(name); + if (!forceRefresh && view.snapshot) { + this.publish(name, { state: 'loaded', snapshot: view.snapshot }); + return; + } + this.cancel(name); + const op: Operation = { abort: new AbortController(), deadline: Infinity }; + this.operations.set(name, op); + this.publish(name, { state: 'loading', snapshot: this.read(name).snapshot }); + // Bound an unresponsive initial POST too; once pending arrives, only the + // controller's fixed deadline governs polling. + op.timer = this.clock.setTimeout(() => this.fail(name, op, 'Detail request timed out'), 120000); + void this.transport.request(name, forceRefresh, op.abort.signal) + .then((result) => this.accept(name, op, result)) + .catch((error) => this.fail(name, op, String(error.message || error))); + } + + private current(name: string, op: Operation) { + return this.operations.get(name) === op && !op.abort.signal.aborted; + } + + private finish(name: string, op: Operation) { + if (op.timer !== undefined) this.clock.clearTimeout(op.timer); + this.operations.delete(name); + op.abort.abort(); + } + + private fail(name: string, op: Operation, error: string, state: 'error' | 'expired' = 'error') { + if (!this.current(name, op)) return; + this.finish(name, op); + this.publish(name, { state, error, snapshot: this.read(name).snapshot }); + } + + private armDeadline(name: string, op: Operation) { + op.timer = this.clock.setTimeout(() => { + if (!this.current(name, op)) return; + if (this.clock.now() >= op.deadline) { + this.fail(name, op, 'Detail request deadline expired', 'expired'); + } else { + this.armDeadline(name, op); + } + }, Math.min(Math.max(0, op.deadline - this.clock.now()), 2147483647)); + } + + private accept(name: string, op: Operation, result: NodeDetailResult) { + if (!this.current(name, op)) return; + if (this.clock.now() >= op.deadline) { + this.fail(name, op, 'Detail request deadline expired', 'expired'); + return; + } + if (result.nodeName !== name || (op.requestId && result.requestId !== op.requestId)) { + this.fail(name, op, 'Mismatched node or detail request ID'); + return; + } + if (result.state === 'pending') { + const deadline = Date.parse(result.deadline || ''); + if (!result.requestId || !Number.isFinite(deadline)) { + this.fail(name, op, 'Pending detail response is missing a request ID or deadline'); + return; + } + op.requestId = result.requestId; + op.deadline = Math.min(op.deadline, deadline); + if (op.timer !== undefined) this.clock.clearTimeout(op.timer); + const remaining = op.deadline - this.clock.now(); + if (remaining <= 0) { + this.fail(name, op, 'Detail request deadline expired', 'expired'); + return; + } + this.publish(name, { + ...this.read(name), state: 'loading', error: result.error, + deadline: new Date(op.deadline).toISOString(), + }); + op.timer = this.clock.setTimeout(() => { + if (!this.current(name, op)) return; + if (this.clock.now() >= op.deadline) { + this.fail(name, op, 'Detail request deadline expired', 'expired'); + return; + } + this.armDeadline(name, op); + void this.transport.poll(name, op.requestId!, op.abort.signal) + .then((next) => this.accept(name, op, next)) + .catch((error) => this.fail(name, op, String(error.message || error))); + }, Math.min(1000, remaining)); + return; + } + if (result.state !== 'complete') { + this.fail(name, op, result.error || `Detail request ${result.state}`, result.state === 'expired' ? 'expired' : 'error'); + return; + } + const snapshot = result.details; + if (!snapshot || !result.requestId || snapshot.nodeName !== name || + snapshot.status?.nodeInfo?.name !== name || snapshot.requestId !== result.requestId) { + this.fail(name, op, 'Invalid detail snapshot identity'); + return; + } + const expiry = Date.parse(snapshot.expiresAt); + if (!(expiry > this.clock.now())) { + this.fail(name, op, 'Detail snapshot expired', 'expired'); + return; + } + this.finish(name, op); + this.publish(name, { state: 'loaded', snapshot }); + this.scheduleExpiry(name); + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 79530d5a5..1712bc899 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -261,6 +261,8 @@ export type ClusterSummaryDelta = { }; export type NodeSummary = { + nodeInfo?: NodeInfo; + lastPushTime?: string; name?: string; siteName?: string; isGateway?: boolean; @@ -275,4 +277,23 @@ export type NodeSummary = { routeCount?: number; routeMismatch?: boolean; fetchError?: string; + wireGuardOnline?: boolean; +}; + +export type NodeDetailSnapshot = { + nodeName: string; + requestId: string; + collectedAt: string; + receivedAt: string; + expiresAt: string; + status: NodeStatus; +}; + +export type NodeDetailResult = { + state: 'pending' | 'complete' | 'expired' | 'unavailable' | 'retryable'; + nodeName: string; + requestId?: string; + deadline?: string; + error?: string; + details?: NodeDetailSnapshot; }; diff --git a/frontend/tests/clusterSummary.test.ts b/frontend/tests/clusterSummary.test.ts new file mode 100644 index 000000000..26adc720f --- /dev/null +++ b/frontend/tests/clusterSummary.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +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 = { + nodeInfo: { name: 'worker', siteName: 'site', wireGuard: { interface: 'wg0' } }, + statusSource: 'push', + peers: [ + { healthCheck: { enabled: true, status: 'up' } }, + { healthCheck: { enabled: true, status: 'down' } }, + { tunnel: { lastHandshake: new Date(99000).toISOString() } }, + { tunnel: { lastHandshake: new Date(0).toISOString() } }, + ], + routingTable: { routes: [{ nextHops: [{ expected: true, present: false }] }] }, + bpfEntries: [{ cidr: 'hidden' }], + }; + const projected = toClusterSummary({ + nodes: [node], nodeCount: 1, siteCount: 1, + sites: [{ name: 'site' }], gatewayPools: [{ name: 'pool' }], peerings: [{ name: 'peering' }], + }, 200000); + assert.equal(projected.nodeSummaries[0].healthyPeers, 2); + assert.equal(projected.nodeSummaries[0].peerCount, 4); + assert.equal(projected.nodeSummaries[0].routeCount, 1); + assert.equal(projected.nodeSummaries[0].cniStatus, 'Route mismatch'); + assert.equal(projected.nodeSummaries[0].wireGuardOnline, true); + assert.deepEqual(projected.peerings, [{ name: 'peering' }]); + assert.deepEqual(projected.sites, [{ name: 'site' }]); + assert.deepEqual(projected.gatewayPools, [{ name: 'pool' }]); + for (const key of ['"nodes"', '"peers"', '"routingTable"', '"nextHops"', '"bpfEntries"']) { + assert.equal(JSON.stringify(projected).includes(key), false, key); + } +}); + +test('summary input wins over legacy full fields and is itself whitelisted', () => { + const summary = toClusterSummary({ + nodeSummaries: [{ name: 'node', peerCount: 19, peers: [{ name: 'hidden' }] }], + nodes: [{ nodeInfo: { name: 'hidden' } }], + } as never); + assert.equal(summary.nodeSummaries[0].peerCount, 19); + assert.equal(JSON.stringify(summary).includes('hidden'), false); +}); + +test('summary metadata and freshness survive snapshots and deltas without diagnostic arrays', () => { + const nodeInfo = { + name: 'node', internalIPs: ['192.0.2.1'], providerId: 'azure://vm', + k8sReady: 'Ready', k8sUpdatedAt: '2026-09-17T00:00:00Z', + buildInfo: { version: 'test', peers: ['hidden'] }, + wireGuard: { interface: 'wg0', publicKey: 'public-key', peers: ['hidden'] }, + peers: ['hidden'], bpfEntries: ['hidden'], + }; + const projected = toClusterSummary({ nodes: [{ + nodeInfo, lastPushTime: '2026-09-17T00:01:00Z', peers: [{ name: 'hidden' }], + }] }); + const initial = toClusterSummary({ seq: 1, nodeSummaries: projected.nodeSummaries }); + assert.equal(initial.nodeSummaries[0].nodeInfo.providerId, 'azure://vm'); + assert.equal(initial.nodeSummaries[0].nodeInfo.wireGuard.publicKey, 'public-key'); + assert.equal(initial.nodeSummaries[0].lastPushTime, '2026-09-17T00:01:00Z'); + assert.equal(JSON.stringify(initial).includes('hidden'), false); + const next = mergeSummary(initial, { + seq: 2, nodeSummaries: [{ ...initial.nodeSummaries[0], lastPushTime: '2026-09-17T00:02:00Z' }], + }); + assert.equal(next.nodeSummaries[0].lastPushTime, '2026-09-17T00:02:00Z'); + const legacy = mergeLegacySummary(next, { + seq: 3, updatedNodes: [{ nodeInfo: { ...nodeInfo, kernel: 'new-kernel' } }], + }); + assert.equal(legacy.nodeSummaries[0].nodeInfo.kernel, 'new-kernel'); + assert.equal(legacy.nodeSummaries[0].lastPushTime, '2026-09-17T00:02:00Z'); + assert.equal(JSON.stringify(legacy).includes('hidden'), false); +}); + +test('CNI priority preserves no-data, errors, unknown and health', () => { + assert.equal(summarizeNode({ statusSource: 'no-data', fetchError: 'error' }).cniStatus, 'No data'); + assert.equal(summarizeNode({ fetchError: 'error' }).cniStatus, 'Fetch error'); + const errors = summarizeNode({ nodeErrors: [{ message: 'bootstrap blocked' }] }); + assert.equal(errors.firstError, 'bootstrap blocked'); + assert.equal(errors.cniTone, 'danger'); + assert.equal(summarizeNode({}).cniStatus, 'Unknown'); + assert.equal(summarizeNode({ statusSource: 'stale' }).cniStatus, 'Stale'); + 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); + const next = mergeSummary(initial, { seq: 5, removedNodes: ['old'], nodeSummaries: [{ name: 'new' }] }); + assert.deepEqual(next.nodeSummaries, toClusterSummary({ nodeSummaries: [{ name: 'new' }] }).nodeSummaries); + assert.deepEqual(next.sites, initial.sites); +}); + +test('legacy partial updates keep counts and CNI facts without retaining full base', () => { + const initial = toClusterSummary({ nodes: [{ + nodeInfo: { name: 'node', siteName: 'site' }, statusSource: 'push', + peers: [{ healthCheck: { enabled: true, status: 'up' } }], + routingTable: { routes: [{ nextHops: [{ expected: true }] }] }, + }] }); + const next = mergeLegacySummary(initial, { updatedNodes: [{ nodeInfo: { name: 'node' }, lastPushTime: 'changed' }] }); + assert.equal(next.nodeSummaries[0].peerCount, 1); + assert.equal(next.nodeSummaries[0].routeMismatch, true); + assert.equal(next.nodeSummaries[0].cniStatus, 'Route mismatch'); + assert.equal(JSON.stringify(next).includes('nextHops'), false); + 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); +}); diff --git a/frontend/tests/nodeDetails.test.ts b/frontend/tests/nodeDetails.test.ts new file mode 100644 index 000000000..5404e3d54 --- /dev/null +++ b/frontend/tests/nodeDetails.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { NodeDetails } from '../src/state/nodeDetails.ts'; + +const tick = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }; +const time = (value: number) => new Date(value).toISOString(); +function fixture() { + let now = 1000; + let id = 0; + const timers = new Map void }>(); + const calls: { name: string; force?: boolean; signal: AbortSignal; resolve: (value: any) => void; reject: (error: Error) => void }[] = []; + const send = (name: string, signal: AbortSignal, force?: boolean) => + new Promise((resolve, reject) => calls.push({ name, signal, force, resolve, reject })); + const store = new NodeDetails({ + request: (name, force, signal) => send(name, signal, force), + poll: (name, _id, signal) => send(name, signal), + }, () => {}, { + now: () => now, + setTimeout: (run, delay) => { timers.set(++id, { at: now + delay, run }); return id as any; }, + clearTimeout: (id) => { timers.delete(id as any); }, + }); + const advance = (value: number, runTimers = true) => { + now = value; + if (runTimers) for (const [id, timer] of [...timers]) { + if (timer.at <= now) { timers.delete(id); timer.run(); } + } + }; + const complete = (requestId = 'a', expiresAt = 5000, nodeName = 'node') => ({ + state: 'complete', nodeName, requestId, + details: { + nodeName, requestId, collectedAt: time(1000), receivedAt: time(1000), expiresAt: time(expiresAt), + status: { nodeInfo: { name: nodeName }, peers: [{ name: 'peer' }], bpfEntries: [{ cidr: 'heavy' }] }, + }, + }); + return { store, calls, timers, advance, complete }; +} + +test('reads/open/summary/reconnect do not load; explicit load reuses cache and refresh forces', async () => { + const f = fixture(); + for (let i = 0; i < 5; i++) assert.equal(f.store.read('node').state, 'not-loaded'); + assert.equal(f.calls.length, 0); + f.store.load('node'); + assert.equal(f.calls[0].force, false); + f.calls[0].resolve(f.complete()); + await tick(); + f.store.load('node'); + assert.equal(f.calls.length, 1); + f.store.load('node', true); + assert.equal(f.calls[1].force, true); + f.calls[1].reject(new Error('leadership unavailable')); + await tick(); + assert.equal(f.store.read('node').state, 'error'); + assert.equal(f.store.read('node').error, 'leadership unavailable'); + assert.equal(f.store.read('node').snapshot.requestId, 'a'); + f.advance(5000); + assert.equal(f.store.read('node').snapshot, undefined); + assert.equal(f.calls.length, 2); +}); + +test('expiry actively releases data and read-time expiry cannot extend TTL', async () => { + for (const runTimers of [true, false]) { + const f = fixture(); + f.store.load('node'); + f.calls[0].resolve(f.complete()); + await tick(); + f.advance(4999); + assert.equal(f.store.read('node').state, 'loaded'); + f.advance(5000, runTimers); + if (runTimers) assert.equal(f.store['views'].get('node').snapshot, undefined, 'expiry releases data before a read'); + assert.deepEqual(f.store.read('node'), { state: 'expired', error: undefined, deadline: undefined }); + assert.equal(f.calls.length, 1); + } +}); + +test('selection cancellation and superseded refresh reject stale async payloads', async () => { + const f = fixture(); + f.store.load('node'); + f.store.cancel('node'); + assert.equal(f.calls[0].signal.aborted, true); + f.calls[0].resolve(f.complete()); + await tick(); + assert.equal(f.store.read('node').snapshot, undefined); + f.store.load('node'); + f.store.load('node', true); + f.calls[2].resolve(f.complete('new', 9000)); + f.calls[1].resolve(f.complete('old')); + await tick(); + assert.equal(f.store.read('node').snapshot.requestId, 'new'); + f.advance(5000); + assert.equal(f.store.read('node').snapshot.requestId, 'new'); +}); + +test('pending polling honors original deadline, aborts in-flight GET, rejects late replies', async () => { + const f = fixture(); + f.store.load('node'); + f.calls[0].resolve({ state: 'pending', nodeName: 'node', requestId: 'a', deadline: time(4000) }); + await tick(); + f.advance(2000); + assert.equal(f.calls.length, 2); + f.calls[1].resolve({ state: 'pending', nodeName: 'node', requestId: 'a', deadline: time(9000) }); + await tick(); + assert.equal(f.store.read('node').deadline, time(4000)); + f.advance(3000); + f.advance(4000); + assert.equal(f.calls[2].signal.aborted, true); + assert.equal(f.store.read('node').state, 'expired'); + f.calls[2].resolve(f.complete()); + await tick(); + assert.equal(f.store.read('node').snapshot, undefined); +}); + +test('invalid and expired responses surface errors, not empty success', async () => { + for (const result of [ + { state: 'unavailable', nodeName: 'node', error: 'offline' }, + { state: 'pending', nodeName: 'node', requestId: 'a' }, + { state: 'complete', nodeName: 'other' }, + ]) { + const f = fixture(); + f.store.load('node'); + f.calls[0].resolve(result); + await tick(); + assert.equal(f.store.read('node').state, 'error'); + assert.equal(f.store.read('node').snapshot, undefined); + } + const f = fixture(); + f.store.load('node'); + f.calls[0].resolve(f.complete('a', 1000)); + await tick(); + assert.equal(f.store.read('node').state, 'expired'); +}); + +test('dispose cancels pending work, clears timers/cache, and ignores late results', async () => { + const f = fixture(); + f.store.load('node'); + f.store.dispose(); + assert.equal(f.calls[0].signal.aborted, true); + assert.equal(f.timers.size, 0); + f.calls[0].resolve(f.complete()); + await tick(); + assert.equal(f.store.read('node').snapshot, undefined); +}); + +test('loading twice joins a browser waiter; pending GET failures retain valid previous data', async () => { + const f = fixture(); + f.store.load('node'); + f.store.load('node'); + assert.equal(f.calls.length, 1); + f.calls[0].resolve(f.complete()); + await tick(); + f.store.load('node', true); + f.calls[1].resolve({ state: 'pending', nodeName: 'node', requestId: 'new', deadline: time(4000) }); + await tick(); + f.store.load('node'); + assert.equal(f.store.read('node').state, 'loading'); + f.advance(2000); + f.calls[2].reject(new Error('GET failed')); + await tick(); + assert.equal(f.store.read('node').state, 'error'); + assert.equal(f.store.read('node').snapshot.requestId, 'a'); + f.advance(5000); + assert.equal(f.store['views'].get('node').snapshot, undefined); +}); + +test('large TTLs rearm browser-safe timers without expiring early', async () => { + const f = fixture(); + const expires = 2147483647 + 5000; + f.store.load('node'); + f.calls[0].resolve(f.complete('long-lived', expires)); + await tick(); + f.advance(2147483647 + 1000); + assert.equal(f.store.read('node').state, 'loaded'); + f.advance(expires); + assert.equal(f.store['views'].get('node').snapshot, undefined); +}); + +test('initial POST timeout and GET identity mismatch cannot revive snapshots', async () => { + const f = fixture(); + f.store.load('node'); + f.advance(121000); + assert.equal(f.calls[0].signal.aborted, true); + f.calls[0].resolve(f.complete('late', 150000)); + await tick(); + assert.equal(f.store.read('node').snapshot, undefined); + f.store.load('node'); + f.calls[1].resolve({ state: 'pending', nodeName: 'node', requestId: 'expected', deadline: time(150000) }); + await tick(); + f.advance(122000); + f.calls[2].resolve(f.complete('wrong', 150000)); + await tick(); + assert.match(f.store.read('node').error, /Mismatched/); +});