From 456ee2169b3863c8a9d717db60fff8c805c98e69 Mon Sep 17 00:00:00 2001 From: "Patrick W. Healy" Date: Wed, 16 Sep 2026 21:46:37 +0000 Subject: [PATCH] frontend: wire summary streams and button-only node diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2243398-6c36-4c3d-969e-7ed7bfb5b459 --- frontend/src/App.tsx | 80 ++--- frontend/src/api.ts | 6 +- frontend/src/hooks/useClusterStatus.ts | 462 ++++--------------------- frontend/tests/api.test.ts | 54 +++ 4 files changed, 158 insertions(+), 444 deletions(-) create mode 100644 frontend/tests/api.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fcf712c6c..c00b23cf0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,21 +12,25 @@ import { } from './components/nodes/shared/index'; 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/NodeDetailModal')); +const NodeDetailModal = React.lazy(() => import('./components/nodes/NodeDetailDialog')); +const noFullNodes: NodeStatus[] = []; +const noLegacyDetail = () => undefined; export default function App() { const { - summary, status, loading, error, wsConnected, sendWsMessage, - nodeDetail, requestNodeDetail, subscribeNodeDetail, unsubscribeNodeDetail + summary, loading, error, wsConnected, sendWsMessage } = useClusterStatus(); - const nodes = status?.nodes || []; - const sites = summary?.sites || status?.sites || []; - const gatewayPools = summary?.gatewayPools || status?.gatewayPools || []; + const nodes = noFullNodes; + const sites = summary?.sites || []; + const gatewayPools = summary?.gatewayPools || []; const nodeSummaries = summary?.nodeSummaries || []; const [hiddenSites, setHiddenSites] = useState>(new Set()); const [hiddenGatewayPools, setHiddenGatewayPools] = useState>(new Set()); const [selectedNodeName, setSelectedNodeName] = useState(null); + const { detail, load: loadNodeDetail } = useNodeDetails(selectedNodeName); const [selectedNodeDetailTab, setSelectedNodeDetailTab] = useState<'peerings' | 'routes' | 'bpf'>('peerings'); const [pullEnabledOptimistic, setPullEnabledOptimistic] = useState(null); const [selectedNodeTypesFilter, setSelectedNodeTypesFilter] = useState>(new Set(['Gateway', 'Worker'])); @@ -82,23 +86,13 @@ export default function App() { setInfoOpen(false); }, [statusJsonOpen]); - // Request and subscribe to node detail when a node is selected - useEffect(() => { - if (!selectedNodeName) return; - requestNodeDetail(selectedNodeName); - subscribeNodeDetail(selectedNodeName); - return () => { - unsubscribeNodeDetail(selectedNodeName); - }; - }, [selectedNodeName, requestNodeDetail, subscribeNodeDetail, unsubscribeNodeDetail]); - useEffect(() => { if (pullEnabledOptimistic === null) return; - const pullEnabled = summary?.pullEnabled ?? status?.pullEnabled; + const pullEnabled = summary?.pullEnabled; if (typeof pullEnabled === 'boolean' && pullEnabled === pullEnabledOptimistic) { setPullEnabledOptimistic(null); } - }, [summary?.pullEnabled, status?.pullEnabled, pullEnabledOptimistic]); + }, [summary?.pullEnabled, pullEnabledOptimistic]); useEffect(() => { if (pullEnabledOptimistic === null) return; @@ -112,31 +106,20 @@ export default function App() { const activeErrors = useMemo(() => { const items: string[] = []; if (error) items.push(`controller: ${error}`); - const errors = summary?.errors || status?.errors || []; + const errors = summary?.errors || []; for (const msg of errors) { items.push(`controller: ${msg}`); } items.sort(); return items; - }, [error, summary?.errors, status?.errors]); + }, [error, summary?.errors]); const activeWarnings = useMemo(() => { const items: string[] = []; - const warnings = summary?.warnings || status?.warnings || []; + const warnings = summary?.warnings || []; for (const msg of warnings) { items.push(`controller: ${msg}`); } - // Use full node data for detailed error messages when available - const fullNodes = status?.nodes || []; - if (fullNodes.length > 0) { - for (const node of fullNodes) { - const name = node.nodeInfo?.name || 'unknown'; - for (const ne of node.nodeErrors || []) { - const msg = (ne.message || '').trim(); - if (msg) items.push(`node ${name}: ${msg}`); - } - } - } else { - // Fall back to summary error counts / first error message + // Summary errors stay independent of explicitly loaded diagnostics. for (const ns of nodeSummaries) { const count = ns.errorCount || 0; if (count === 1 && ns.firstError) { @@ -145,10 +128,9 @@ export default function App() { items.push(`node ${ns.name || 'unknown'}: ${count} error(s)`); } } - } items.sort(); return items; - }, [summary?.warnings, status?.warnings, status?.nodes, nodeSummaries]); + }, [summary?.warnings, nodeSummaries]); useEffect(() => { const key = activeErrors.join('\n'); @@ -208,7 +190,6 @@ export default function App() { }, []); const { - activeSelectedNode, effectivePullEnabled, gatewayByNode, nodeK8sStatusMap, @@ -220,7 +201,7 @@ export default function App() { visibleNodeSummaries } = useDashboardData({ summary, - status, + status: null, nodes, nodeSummaries, gatewayPools, @@ -229,7 +210,7 @@ export default function App() { selectedNodeTypesFilter, pullEnabledOptimistic, selectedNodeName, - nodeDetail + nodeDetail: noLegacyDetail }); // All known node names for the detail modal's peer navigation @@ -251,10 +232,10 @@ export default function App() { } }, [nodeSummaries, nodes, selectedNodeName]); - const wsState = wsConnected ? 'ok' : (summary || status) ? 'warn' : 'err'; + const wsState = wsConnected ? 'ok' : summary ? 'warn' : 'err'; const wsLabel = wsConnected ? 'WebSocket connected' - : (summary || status) + : summary ? 'Polling only' : 'No data'; @@ -285,10 +266,9 @@ export default function App() { return
{content}
; }; - // Build info from summary or status - const buildInfo = summary?.buildInfo || status?.buildInfo; - const leaderInfo = summary?.leaderInfo || status?.leaderInfo; - const timestamp = summary?.timestamp || status?.timestamp; + const buildInfo = summary?.buildInfo; + const leaderInfo = summary?.leaderInfo; + const timestamp = summary?.timestamp; if (maximizedPanel) { return ( @@ -298,11 +278,12 @@ export default function App() {
Sites
-
{(summary?.siteCount ?? status?.siteCount ?? sites.length).toLocaleString()}
+
{(summary?.siteCount ?? sites.length).toLocaleString()}
Gateway Pools
@@ -519,11 +500,12 @@ export default function App() { { +export async function fetchClusterStatus(signal?: AbortSignal): Promise { const url = buildControllerUrl('/status/json'); try { - const res = await fetch(url); + const res = await fetch(url, { signal, cache: 'no-store', credentials: 'same-origin' }); if (!res.ok) { let details = ''; try { diff --git a/frontend/src/hooks/useClusterStatus.ts b/frontend/src/hooks/useClusterStatus.ts index 7e9500520..b288a03ba 100644 --- a/frontend/src/hooks/useClusterStatus.ts +++ b/frontend/src/hooks/useClusterStatus.ts @@ -1,447 +1,125 @@ // Copyright (c) Microsoft Corporation. // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { connectWebSocket, fetchClusterStatus, mergeDelta, StatusEvent } from '../api'; -import { ClusterStatus, ClusterStatusDelta, ClusterSummary, ClusterSummaryDelta, NodeStatus, NodeSummary } from '../types'; - -const refreshIntervalMs = 10000; - -type DiagEntry = { - lastLogAt: number; - suppressed: number; -}; - -const uiDiagEntries = new Map(); - -function resolveUiDiagEnabled() { - if (typeof window === 'undefined') return false; - try { - const params = new URLSearchParams(window.location.search); - const query = params.get('uiDiag'); - if (query === '1' || query === 'true') return true; - if (query === '0' || query === 'false') return false; - - const stored = window.localStorage.getItem('uiDiag'); - if (!stored) return false; - return stored === '1' || stored.toLowerCase() === 'true'; - } catch { - return false; - } -} - -const UI_DIAG_ENABLED = resolveUiDiagEnabled(); - -function uiDiag( - key: string, - message: string, - data?: Record, - options?: { minIntervalMs?: number; level?: 'log' | 'warn' } -) { - if (!UI_DIAG_ENABLED) return; - if (typeof window === 'undefined') return; - const minIntervalMs = options?.minIntervalMs ?? 300; - const level = options?.level ?? 'log'; - const now = performance.now(); - const entry = uiDiagEntries.get(key) || { lastLogAt: 0, suppressed: 0 }; - - if (now - entry.lastLogAt < minIntervalMs) { - entry.suppressed += 1; - uiDiagEntries.set(key, entry); - return; - } - - const payload: Record = { - ...(data || {}), - suppressed: entry.suppressed, - at: new Date().toISOString() - }; - - if (level === 'warn') { - console.warn(`[UI-DIAG] ${message}`, payload); - } else { - console.log(`[UI-DIAG] ${message}`, payload); - } - - entry.lastLogAt = now; - entry.suppressed = 0; - uiDiagEntries.set(key, entry); -} - -// buildNodeSummaryFromNodeStatus derives a NodeSummary from a full NodeStatus, -// mirroring the Go buildClusterSummary / deriveCniStatusAndTone logic. -function buildNodeSummaryFromNodeStatus(node: NodeStatus): NodeSummary { - const peers = node.peers || []; - let healthyPeers = 0; - const now = Date.now(); - for (const peer of peers) { - if (peer.healthCheck?.enabled) { - const status = (peer.healthCheck.status || '').toLowerCase(); - if (status === 'up') { - healthyPeers++; - } - } else if (peer.tunnel?.lastHandshake) { - const handshakeTime = new Date(peer.tunnel.lastHandshake).getTime(); - if (now - handshakeTime < 3 * 60 * 1000) { - healthyPeers++; - } - } - } - - let routeMismatch = false; - for (const route of node.routingTable?.routes || []) { - for (const hop of route.nextHops || []) { - if ((hop.expected === true) !== (hop.present === true)) { - routeMismatch = true; - break; - } - } - if (routeMismatch) break; - } - - let cniStatus = 'Unknown'; - let cniTone = 'warning'; - if (node.fetchError) { - cniStatus = 'Fetch error'; - cniTone = 'danger'; - } else if ((node.nodeErrors || []).length > 0) { - cniStatus = 'Errors'; - cniTone = 'danger'; - } else if (routeMismatch) { - cniStatus = 'Route mismatch'; - cniTone = 'warning'; - } else { - const src = node.statusSource || ''; - if (src === 'stale' || src === 'error') { - cniStatus = 'Stale'; - cniTone = 'warning'; - } else if (src === '') { - cniStatus = 'Unknown'; - cniTone = 'warning'; - } else { - cniStatus = 'Healthy'; - cniTone = 'success'; - } - } - - return { - name: node.nodeInfo?.name, - siteName: node.nodeInfo?.siteName, - isGateway: node.nodeInfo?.isGateway, - k8sReady: node.nodeInfo?.k8sReady, - statusSource: node.statusSource, - cniStatus, - cniTone, - errorCount: (node.nodeErrors || []).length, - peerCount: peers.length, - healthyPeers, - routeCount: (node.routingTable?.routes || []).length, - routeMismatch, - fetchError: node.fetchError, - }; -} - -// buildSummaryFromFullStatus converts a ClusterStatus to a ClusterSummary. -// Used for backward compatibility when the server sends full status. -function buildSummaryFromFullStatus(cs: ClusterStatus): ClusterSummary { - const nodes = cs.nodes || []; - return { - timestamp: cs.timestamp, - nodeCount: cs.nodeCount, - siteCount: cs.siteCount, - azureTenantId: cs.azureTenantId, - leaderInfo: cs.leaderInfo, - buildInfo: cs.buildInfo, - sites: cs.sites, - gatewayPools: cs.gatewayPools, - peerings: cs.peerings, - errors: cs.errors, - warnings: cs.warnings, - problems: cs.problems, - pullEnabled: cs.pullEnabled, - nodeSummaries: nodes.map(buildNodeSummaryFromNodeStatus), - }; -} +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'; function useClusterStatus() { - // Summary from WS cluster_summary messages (new protocol) - const [summaryFromWs, setSummaryFromWs] = useState(null); - // Full status from HTTP fetch or old-format WS messages (backward compat) - const [status, setStatus] = useState(null); - // On-demand node detail cache - const [nodeDetailCache, setNodeDetailCache] = useState>(() => new Map()); + const [summary, setSummary] = useState(null); + const summaryRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [wsConnected, setWsConnected] = useState(false); const wsRef = useRef(null); - const nodeSubscriptionsRef = useRef>(new Set()); - // Ref for delta merge so we always have the latest status - const statusRef = useRef(null); - - // Effective summary: prefer WS summary, fall back to derived from full status - const summary = useMemo(() => { - if (summaryFromWs) return summaryFromWs; - if (status) return buildSummaryFromFullStatus(status); - return null; - }, [summaryFromWs, status]); useEffect(() => { + let disposed = false; let polling: number | undefined; let reconnectTimer: number | undefined; - - const updateNodeDetailCacheFromStatus = (cs: ClusterStatus) => { - const nodes = cs.nodes || []; - if (nodes.length === 0) return; - setNodeDetailCache((prev) => { - const next = new Map(prev); - for (const node of nodes) { - const name = node.nodeInfo?.name; - if (name) next.set(name, node); - } - return next; - }); + let keepalive: number | undefined; + let revision = 0; + let fetching = false; + const abort = new AbortController(); + const update = (next: ClusterSummary | null) => { + if (disposed || !next) return; + // Projection occurs before React's update queue: no queued callback holds + // a legacy full response, even briefly across subsequent renders. + summaryRef.current = next; + setSummary(next); + setError(null); + setLoading(false); + revision++; }; - - const refresh = async (background?: boolean) => { - if (!background) { - setLoading(true); - } + const refresh = async () => { + if (fetching || disposed) return; + fetching = true; + const startedAtRevision = revision; try { - const data = await fetchClusterStatus(); - statusRef.current = data; - setStatus(data); - updateNodeDetailCacheFromStatus(data); - setError(null); + const data = await fetchClusterStatus(abort.signal); + if (!disposed && startedAtRevision === revision) update(toClusterSummary(data)); } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } + if (!disposed) { setError((err as Error).message); setLoading(false); } + } finally { fetching = false; } }; - - const schedulePoll = () => { - if (polling) return; - polling = window.setInterval(() => { - refresh(true); - }, refreshIntervalMs); - }; - const stopPoll = () => { - if (polling) { - window.clearInterval(polling); - polling = undefined; - } + if (polling !== undefined) window.clearInterval(polling); + polling = undefined; + }; + const schedulePoll = () => { + if (disposed || polling !== undefined) return; + void refresh(); + polling = window.setInterval(() => void refresh(), 10000); }; - const handleMessage = (event: StatusEvent) => { - uiDiag('ws-event', 'websocket event', { type: event.type }, { minIntervalMs: 250 }); - - if (event.type === 'cluster_summary') { - const cs = event.data as ClusterSummary; - uiDiag('ws-cluster-summary', 'cluster summary received', { - nodeCount: cs.nodeSummaries?.length ?? 0 - }, { minIntervalMs: 500 }); - setSummaryFromWs((prev) => { - // Only accept if seq is at least as recent as what we have - if (prev && typeof prev.seq === 'number' && typeof cs.seq === 'number' && cs.seq < prev.seq) { - return prev; - } - return cs; - }); - setError(null); - setLoading(false); + 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') { - const delta = event.data as ClusterSummaryDelta; - setSummaryFromWs((prev) => { - if (!prev) return prev; - // Skip stale deltas - if (typeof prev.seq === 'number' && typeof delta.seq === 'number' && delta.seq <= prev.seq) { - return prev; - } - const merged = { ...prev }; - if (delta.seq != null) merged.seq = delta.seq; - if (delta.timestamp != null) merged.timestamp = delta.timestamp; - if (delta.nodeCount != null) merged.nodeCount = delta.nodeCount; - if (delta.siteCount != null) merged.siteCount = delta.siteCount; - if (delta.azureTenantId != null) merged.azureTenantId = delta.azureTenantId; - if (delta.leaderInfo !== undefined) merged.leaderInfo = delta.leaderInfo; - if (delta.buildInfo !== undefined) merged.buildInfo = delta.buildInfo; - if (delta.sites) merged.sites = delta.sites; - if (delta.gatewayPools) merged.gatewayPools = delta.gatewayPools; - if (delta.peerings) merged.peerings = delta.peerings; - if (delta.errors) merged.errors = delta.errors; - if (delta.warnings) merged.warnings = delta.warnings; - if (delta.problems) merged.problems = delta.problems; - if (delta.pullEnabled != null) merged.pullEnabled = delta.pullEnabled; - if (delta.nodeSummaries || delta.removedNodes) { - const byName = new Map(); - for (const ns of prev.nodeSummaries || []) { - if (ns.name) byName.set(ns.name, ns); - } - if (delta.removedNodes) { - for (const name of delta.removedNodes) byName.delete(name); - } - if (delta.nodeSummaries) { - for (const ns of delta.nodeSummaries) { - if (ns.name) byName.set(ns.name, ns); - } - } - merged.nodeSummaries = Array.from(byName.values()) - .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); - } - return merged; - }); - } else if (event.type === 'cluster_status') { - // Backward compat: old server sends full status - const fullStatus = event.data as ClusterStatus; - const nodeCount = fullStatus?.nodes?.length ?? 0; - uiDiag('ws-cluster-status', 'full cluster status received', { nodeCount }, { minIntervalMs: 500 }); - statusRef.current = fullStatus; - setStatus(fullStatus); - updateNodeDetailCacheFromStatus(fullStatus); - setError(null); - setLoading(false); + if (!summaryRef.current) { void refresh(); return; } + update(mergeSummary(summaryRef.current, event.data as ClusterSummaryDelta)); } else if (event.type === 'cluster_status_delta') { - // Backward compat: merge delta into cached full status - const start = performance.now(); - const merged = mergeDelta(statusRef.current, event.data as ClusterStatusDelta); - const durationMs = Math.round((performance.now() - start) * 100) / 100; - uiDiag( - 'ws-delta-merge', - 'delta merged', - { - durationMs, - prevNodes: statusRef.current?.nodes?.length ?? 0, - nextNodes: merged?.nodes?.length ?? 0 - }, - { minIntervalMs: 300, level: durationMs >= 30 ? 'warn' : 'log' } - ); - statusRef.current = merged; - setStatus(merged); - updateNodeDetailCacheFromStatus(merged); - setError(null); - } else if (event.type === 'node_detail_response' || event.type === 'node_detail_update') { - const nodeName = (event as StatusEvent).nodeName; - const nodeData = event.data as NodeStatus; - if (nodeName && nodeData) { - uiDiag('ws-node-detail', 'node detail received', { nodeName, type: event.type }, { minIntervalMs: 200 }); - setNodeDetailCache((prev) => { - const next = new Map(prev); - next.set(nodeName, nodeData); - return next; - }); - } + if (!summaryRef.current) { void refresh(); return; } + update(mergeLegacySummary(summaryRef.current, event.data as ClusterStatusDelta)); } + // Unsolicited legacy node details are deliberately ignored. }; - const connect = () => { - let keepaliveTimer: number | undefined; + if (disposed) return; let lastMessageTime = Date.now(); - const ws = connectWebSocket( - (event) => { - lastMessageTime = Date.now(); - handleMessage(event); - }, + (event) => { lastMessageTime = Date.now(); handleMessage(event); }, () => { + if (disposed) { ws?.close(); return; } setWsConnected(true); setError(null); stopPoll(); lastMessageTime = Date.now(); - // Subscribe to cluster summary on connect - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'cluster_summary_subscribe' })); - // Re-subscribe to any active node detail subscriptions - for (const name of nodeSubscriptionsRef.current) { - ws.send(JSON.stringify({ type: 'node_detail_subscribe', nodeName: name })); - } - } - // Start keepalive: send ping every 30s, close if no message received in 60s - keepaliveTimer = window.setInterval(() => { - if (Date.now() - lastMessageTime > 60000) { - // No message in 60s -- connection is hung, force reconnect - if (ws) { - try { ws.close(); } catch { /* ignore */ } - } - return; - } - if (ws && ws.readyState === WebSocket.OPEN) { - try { ws.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ } - } + ws?.send(JSON.stringify({ type: 'cluster_summary_subscribe' })); + keepalive = window.setInterval(() => { + if (Date.now() - lastMessageTime > 60000) { ws?.close(); return; } + if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' })); }, 10000); }, () => { - if (keepaliveTimer) { - window.clearInterval(keepaliveTimer); - keepaliveTimer = undefined; - } + if (keepalive !== undefined) window.clearInterval(keepalive); + keepalive = undefined; wsRef.current = null; + if (disposed) return; setWsConnected(false); schedulePoll(); - if (!reconnectTimer) { - reconnectTimer = window.setTimeout(() => { - reconnectTimer = undefined; - connect(); - }, 2000); - } + reconnectTimer = window.setTimeout(() => { reconnectTimer = undefined; connect(); }, 2000); } ); - if (ws) { - wsRef.current = ws; - } else { + wsRef.current = ws; + if (!ws) { schedulePoll(); + reconnectTimer = window.setTimeout(() => { reconnectTimer = undefined; connect(); }, 2000); } }; - - // Connect WS -- the server sends cluster_summary immediately on connect. - // No initial HTTP fetch needed; polling only starts if WS fails. connect(); - return () => { + disposed = true; + abort.abort(); stopPoll(); - if (reconnectTimer) { - window.clearTimeout(reconnectTimer); + if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); + if (keepalive !== undefined) window.clearInterval(keepalive); + const ws = wsRef.current; + if (ws) { + ws.onopen = ws.onclose = ws.onmessage = ws.onerror = null; + ws.close(); } - wsRef.current?.close(); + wsRef.current = null; }; }, []); - const sendWsMessage = useCallback((msg: Record) => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify(msg)); - } + const sendWsMessage = useCallback((message: Record) => { + if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(JSON.stringify(message)); }, []); - const nodeDetail = useCallback((name: string): NodeStatus | undefined => { - return nodeDetailCache.get(name); - }, [nodeDetailCache]); - - const requestNodeDetail = useCallback((name: string) => { - sendWsMessage({ type: 'node_detail_request', nodeName: name }); - }, [sendWsMessage]); - - const subscribeNodeDetail = useCallback((name: string) => { - nodeSubscriptionsRef.current.add(name); - sendWsMessage({ type: 'node_detail_subscribe', nodeName: name }); - }, [sendWsMessage]); - - const unsubscribeNodeDetail = useCallback((name: string) => { - nodeSubscriptionsRef.current.delete(name); - sendWsMessage({ type: 'node_detail_unsubscribe', nodeName: name }); - }, [sendWsMessage]); - - return { - summary, - status, - loading, - error, - wsConnected, - sendWsMessage, - nodeDetail, - requestNodeDetail, - subscribeNodeDetail, - unsubscribeNodeDetail, - }; + return { summary, loading, error, wsConnected, sendWsMessage }; } export default useClusterStatus; diff --git a/frontend/tests/api.test.ts b/frontend/tests/api.test.ts new file mode 100644 index 000000000..3d04bf4c7 --- /dev/null +++ b/frontend/tests/api.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchClusterStatus, pollNodeDetails, requestNodeDetails } from '../src/api.ts'; + +test('detail API uses viewer credentials, escaped names and request IDs, and explicit force intent', async (t) => { + const calls: { url: string; init: RequestInit }[] = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ state: 'pending', nodeName: 'node', requestId: 'a' }), { status: 202 }); + }); + const abort = new AbortController(); + await requestNodeDetails('node / a', false, abort.signal); + await requestNodeDetails('node / a', true, abort.signal); + await pollNodeDetails('node / a', 'request & id', abort.signal); + assert.equal(calls[0].url, '/status/node/node%20%2F%20a/details'); + assert.equal(calls[0].init.method, 'POST'); + assert.equal(calls[0].init.body, '{"forceRefresh":false}'); + assert.equal(calls[1].init.body, '{"forceRefresh":true}'); + assert.equal(calls[2].url, '/status/node/node%20%2F%20a/details?requestId=request%20%26%20id'); + for (const call of calls) { + assert.equal(call.init.credentials, 'same-origin'); + assert.equal(call.init.signal, abort.signal); + } + assert.equal(calls[2].init.cache, 'no-store'); +}); + +test('auth, leadership, malformed and network failures are surfaced', async (t) => { + const responses = [ + new Response('viewer authorization required', { status: 403 }), + new Response(JSON.stringify({ error: 'leadership changed' }), { status: 503 }), + new Response('{}'), + ]; + t.mock.method(globalThis, 'fetch', async () => responses.shift()!); + const signal = new AbortController().signal; + await assert.rejects(requestNodeDetails('node', false, signal), /authorization required/); + await assert.rejects(requestNodeDetails('node', false, signal), /leadership changed/); + await assert.rejects(requestNodeDetails('node', false, signal), /Invalid detail response/); +}); + +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; + t.mock.method(globalThis, 'fetch', async (url, init) => { + assert.equal(url, '/status/json'); + assert.equal(init.signal, signal); + assert.equal(init.cache, 'no-store'); + return new Response(JSON.stringify(responses.shift())); + }); + assert.ok('nodeSummaries' in await fetchClusterStatus(signal)); + assert.ok('nodes' in await fetchClusterStatus(signal)); +});