From 658b445d5905e4875ed279b2e64a30b061ebfcc2 Mon Sep 17 00:00:00 2001 From: "Patrick W. Healy" Date: Thu, 17 Sep 2026 01:16:27 +0000 Subject: [PATCH] fix(net): show node overview before loading diagnostics Keep summary metadata, health and freshness visible independently of the expiring diagnostic tables. Gate tab contents and pagination until explicit collection; retain the overview after failure or expiry. Cover the real dialog lifecycle in Chromium. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2243398-6c36-4c3d-969e-7ed7bfb5b459 --- frontend/README.md | 10 +++- frontend/src/App.tsx | 2 + .../src/components/nodes/NodeDetailDialog.tsx | 42 ++++++++--------- .../src/components/nodes/NodeDetailModal.tsx | 24 ++++++---- frontend/src/components/nodes/NodesTable.tsx | 13 +++--- .../nodes/detail/NodeDetailTabsHeader.tsx | 8 ++-- frontend/src/state/nodeMetadata.ts | 21 +++++++++ frontend/tests/fixtures/nodeDetails.html | 22 +++++++-- frontend/tests/nodeDetails.browser.test.mjs | 35 +++++++++++++- frontend/tests/nodeMetadata.test.ts | 46 +++++++++++++++++++ 10 files changed, 176 insertions(+), 47 deletions(-) create mode 100644 frontend/src/state/nodeMetadata.ts create mode 100644 frontend/tests/nodeMetadata.test.ts diff --git a/frontend/README.md b/frontend/README.md index 34a582a39..ff799c2f1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -10,6 +10,12 @@ 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 left Node Info pane, health badges and last-update ages come from routine +summaries and remain visible before loading and after details expire. Current +summary metadata takes precedence over an older diagnostic snapshot. Unloaded +tabs remain selectable but show no tables, pagination or diagnostic validation +results. The node table shows the last received status age alongside its source. + 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 @@ -54,7 +60,9 @@ cache reuse, refresh failures, cancellation, fixed deadlines and expiry. The optional Chromium regression test mounts the real detail dialog and hook under React StrictMode, using native browser timers and same-origin HTTP requests. It covers button events, correlated polling, cache reuse, failed -refresh, expiry, close/reopen and unmount cancellation. Run it with an existing +refresh, expiry, close/reopen and unmount cancellation. It also checks summary +metadata and health badges, unloaded tabs, and the persistent info pane across +loading, failure and expiry. Run it with an existing Playwright installation and its Chromium browser (no production dependency): ```sh diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ed4a0328..682141b46 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -268,6 +268,7 @@ export default function App() { node.name === selectedNodeName)} detail={detail} onLoad={loadNodeDetail} allNodeNames={allNodeNames} @@ -489,6 +490,7 @@ export default function App() { node.name === selectedNodeName)} detail={detail} onLoad={loadNodeDetail} allNodeNames={allNodeNames} diff --git a/frontend/src/components/nodes/NodeDetailDialog.tsx b/frontend/src/components/nodes/NodeDetailDialog.tsx index 84220f08b..a6bef8c22 100644 --- a/frontend/src/components/nodes/NodeDetailDialog.tsx +++ b/frontend/src/components/nodes/NodeDetailDialog.tsx @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { ComponentProps } from 'react'; import type { DetailView } from '../../state/nodeDetails'; +import { nodeForDetailView } from '../../state/nodeMetadata'; import NodeDetailModal from './NodeDetailModal'; -import { CloseXIcon, formatDateAndAge } from './shared/index'; +import { formatDateAndAge } from './shared/index'; -type Props = Omit, 'node' | 'detailControls'> & { +type Props = Omit, 'node' | 'detailControls' | 'detailsLoaded'> & { detail: DetailView; onLoad: (forceRefresh?: boolean) => void; }; @@ -17,19 +18,17 @@ export default function NodeDetailDialog({ detail, onLoad, ...props }: Props) { const [, setClock] = useState(0); const expiresAt = detail.snapshot?.expiresAt; useEffect(() => { - if (!expiresAt) return; + if (!props.nodeName) return; const timer = window.setInterval(() => setClock((clock) => clock + 1), 1000); return () => window.clearInterval(timer); - }, [expiresAt]); + }, [props.nodeName]); useEffect(() => setJsonOpen(false), [props.nodeName, expiresAt]); - useEffect(() => { - if (!props.nodeName) return; - const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') props.onClose(); }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [props.nodeName, props.onClose]); - if (!props.nodeName) return null; const snapshot = detail.snapshot && Date.parse(detail.snapshot.expiresAt) > Date.now() ? detail.snapshot : undefined; + const node = useMemo( + () => props.nodeName ? nodeForDetailView(props.nodeName, props.summary, snapshot?.status) : null, + [props.nodeName, props.summary, snapshot?.status], + ); + if (!props.nodeName) return null; const busy = detail.state === 'loading'; const collected = snapshot ? formatDateAndAge(snapshot.collectedAt) : undefined; const state = !snapshot && detail.state === 'loaded' ? 'expired' : detail.state; @@ -62,18 +61,13 @@ export default function NodeDetailDialog({ detail, onLoad, ...props }: Props) { ); // Unmount the heavy tables when data expires so their memoized rows, maps, // column callbacks and serialized JSON cannot keep the snapshot alive. - if (snapshot) { - return ; - } return ( -
-
event.stopPropagation()}> -
-
{props.nodeName}
- -
- {controls} -
-
+ ); } diff --git a/frontend/src/components/nodes/NodeDetailModal.tsx b/frontend/src/components/nodes/NodeDetailModal.tsx index 297520fb9..21fe01a27 100644 --- a/frontend/src/components/nodes/NodeDetailModal.tsx +++ b/frontend/src/components/nodes/NodeDetailModal.tsx @@ -12,7 +12,7 @@ import { SortingState, useReactTable } from '@tanstack/react-table'; -import { NodeStatus } from '../../types'; +import { NodeStatus, NodeSummary } from '../../types'; import { CloseXIcon, TableFilterButton, @@ -43,6 +43,8 @@ import NodeDetailTabsHeader from './detail/NodeDetailTabsHeader'; function NodeDetailModal({ detailControls, + detailsLoaded = true, + summary, nodeName, node, allNodeNames, @@ -57,6 +59,8 @@ function NodeDetailModal({ onClose }: { detailControls?: React.ReactNode; + detailsLoaded?: boolean; + summary?: NodeSummary; nodeName: string | null; node: NodeStatus | null; allNodeNames: string[]; @@ -90,7 +94,9 @@ function NodeDetailModal({ const nodeInfo = node?.nodeInfo; const isGateway = nodeInfo?.isGateway || (nodeDisplayName ? gatewayByNode.has(nodeDisplayName) : false); const siteOrPool = (nodeDisplayName ? gatewayByNode.get(nodeDisplayName) : undefined) || nodeInfo?.siteName || '-'; - const cniStatus = node ? getCniStatus(node, pullEnabled) : { label: 'No data', tone: 'danger' as const }; + const cniStatus = summary + ? { label: summary.cniStatus || 'Unknown', tone: summary.cniTone || 'warning' } + : node ? getCniStatus(node, pullEnabled) : { label: 'No data', tone: 'danger' as const }; const cniProblemMessages = node ? getNodeCniProblemMessages(node) : []; // Collect all node errors and health check issues for the errors card. @@ -1698,7 +1704,7 @@ function NodeDetailModal({ {nodeInfo?.k8sReady || 'NotReady'} - {cniStatus.label} + {cniStatus.label} @@ -1707,7 +1713,6 @@ function NodeDetailModal({ - {detailControls}
+ {detailControls} {dataCondition && (
{dataCondition.title}
@@ -1760,14 +1766,16 @@ function NodeDetailModal({ )}
- {detailTab === 'peerings' && ( + {!detailsLoaded &&
Tab data is not loaded. Use Load data to inspect this node.
} + {detailsLoaded && detailTab === 'peerings' && ( <>
@@ -1929,7 +1937,7 @@ function NodeDetailModal({ )} - {detailTab === 'routes' && ( + {detailsLoaded && detailTab === 'routes' && ( <>
@@ -2037,7 +2045,7 @@ function NodeDetailModal({ )} - {detailTab === 'bpf' && ( + {detailsLoaded && detailTab === 'bpf' && ( <>
diff --git a/frontend/src/components/nodes/NodesTable.tsx b/frontend/src/components/nodes/NodesTable.tsx index b71654d0d..912c4dc2e 100644 --- a/frontend/src/components/nodes/NodesTable.tsx +++ b/frontend/src/components/nodes/NodesTable.tsx @@ -17,6 +17,7 @@ import { CloseXIcon, MagnifyPlusIcon, TableFilterButton, + formatDateAndAge, getCountColor, getGatewayPoolBadgeTone, uiDiag, @@ -371,25 +372,23 @@ function NodesTable({ { id: 'lastUpdate', header: 'Last Update', - accessorFn: (row) => { - const src = row.statusSource || ''; - return (src === 'ws' || src === 'apiserver-ws') ? 'Live' : src || '-'; - }, + accessorFn: (row) => row.lastPushTime || '', cell: ({ row }) => { const src = row.original.statusSource || ''; + const updated = row.original.lastPushTime ? formatDateAndAge(row.original.lastPushTime) : undefined; if (src === 'ws' || src === 'apiserver-ws') { const viaAPIServer = src === 'apiserver-ws'; return ( - Live + Live{updated ? ` (${updated.age})` : ''} ); } - return {src || '-'}; + return {updated?.age || src || '-'}; } } ], diff --git a/frontend/src/components/nodes/detail/NodeDetailTabsHeader.tsx b/frontend/src/components/nodes/detail/NodeDetailTabsHeader.tsx index 49a16ccfb..be04c5497 100644 --- a/frontend/src/components/nodes/detail/NodeDetailTabsHeader.tsx +++ b/frontend/src/components/nodes/detail/NodeDetailTabsHeader.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 type NodeDetailTabsHeaderProps = { + detailsLoaded?: boolean; detailTab: 'peerings' | 'routes' | 'bpf'; routesValidationSummary: { total: number; mismatch: number }; bpfEntryCount: number; @@ -10,6 +11,7 @@ type NodeDetailTabsHeaderProps = { }; function NodeDetailTabsHeader({ + detailsLoaded = true, detailTab, routesValidationSummary, bpfEntryCount, @@ -30,7 +32,7 @@ function NodeDetailTabsHeader({ onClick={() => onDetailTabChange('routes')} > Routes - {routesValidationSummary.mismatch > 0 && ( + {detailsLoaded && routesValidationSummary.mismatch > 0 && ( )} - {bpfEntryCount > 0 && ( + {(!detailsLoaded || bpfEntryCount > 0) && (