Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export default function App() {
<Suspense fallback={null}>
<NodeDetailModal
nodeName={selectedNodeName}
summary={nodeSummaries.find((node) => node.name === selectedNodeName)}
detail={detail}
onLoad={loadNodeDetail}
allNodeNames={allNodeNames}
Expand Down Expand Up @@ -489,6 +490,7 @@ export default function App() {
<Suspense fallback={null}>
<NodeDetailModal
nodeName={selectedNodeName}
summary={nodeSummaries.find((node) => node.name === selectedNodeName)}
detail={detail}
onLoad={loadNodeDetail}
allNodeNames={allNodeNames}
Expand Down
42 changes: 18 additions & 24 deletions frontend/src/components/nodes/NodeDetailDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentProps<typeof NodeDetailModal>, 'node' | 'detailControls'> & {
type Props = Omit<ComponentProps<typeof NodeDetailModal>, 'node' | 'detailControls' | 'detailsLoaded'> & {
detail: DetailView;
onLoad: (forceRefresh?: boolean) => void;
};
Expand All @@ -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;
Expand Down Expand Up @@ -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 <NodeDetailModal {...props} node={snapshot.status} detailControls={controls} />;
}
return (
<div className="modal-backdrop" onClick={props.onClose}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">{props.nodeName}</div>
<button className="button zoom-action-button" onClick={props.onClose} aria-label="Close" title="Close"><CloseXIcon /></button>
</div>
{controls}
</div>
</div>
<NodeDetailModal
{...props}
key={snapshot ? 'details' : 'overview'}
node={node}
detailsLoaded={Boolean(snapshot)}
detailControls={controls}
/>
);
}
24 changes: 16 additions & 8 deletions frontend/src/components/nodes/NodeDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
SortingState,
useReactTable
} from '@tanstack/react-table';
import { NodeStatus } from '../../types';
import { NodeStatus, NodeSummary } from '../../types';
import {
CloseXIcon,
TableFilterButton,
Expand Down Expand Up @@ -43,6 +43,8 @@ import NodeDetailTabsHeader from './detail/NodeDetailTabsHeader';

function NodeDetailModal({
detailControls,
detailsLoaded = true,
summary,
nodeName,
node,
allNodeNames,
Expand All @@ -57,6 +59,8 @@ function NodeDetailModal({
onClose
}: {
detailControls?: React.ReactNode;
detailsLoaded?: boolean;
summary?: NodeSummary;
nodeName: string | null;
node: NodeStatus | null;
allNodeNames: string[];
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1698,7 +1704,7 @@ function NodeDetailModal({
<span className={`badge ${nodeInfo?.k8sReady === 'Ready' ? 'success' : 'danger'}`}>
{nodeInfo?.k8sReady || 'NotReady'}
</span>
<span className={`badge ${cniStatus.tone}`} title={node ? getCniStatusTooltip(node, pullEnabled) : undefined}>{cniStatus.label}</span>
<span className={`badge ${cniStatus.tone}`} title={summary ? summary.fetchError || summary.firstError || summary.cniStatus : node ? getCniStatusTooltip(node, pullEnabled) : undefined}>{cniStatus.label}</span>
</div>
</div>
</div>
Expand All @@ -1707,7 +1713,6 @@ function NodeDetailModal({
</button>
</div>

{detailControls}
<div className="node-detail-grid">
<div className="node-detail-left">
<NodeInfoPanel
Expand All @@ -1731,6 +1736,7 @@ function NodeDetailModal({
</div>

<div className="node-detail-right">
{detailControls}
{dataCondition && (
<div className={`card node-modal-card data-condition-card ${dataCondition.tone}`}>
<div className="section-title">{dataCondition.title}</div>
Expand Down Expand Up @@ -1760,14 +1766,16 @@ function NodeDetailModal({
)}
<div className="card node-modal-card">
<NodeDetailTabsHeader
detailsLoaded={detailsLoaded}
detailTab={detailTab}
routesValidationSummary={routesValidationSummary}
bpfEntryCount={node?.bpfEntries?.length ?? 0}
paginationControls={renderDetailPagination()}
paginationControls={detailsLoaded ? renderDetailPagination() : null}
onDetailTabChange={onDetailTabChange}
/>

{detailTab === 'peerings' && (
{!detailsLoaded && <div className="detail-table-wrapper">Tab data is not loaded. Use Load data to inspect this node.</div>}
{detailsLoaded && detailTab === 'peerings' && (
<>
<div className="detail-table-wrapper" ref={peerTableWrapperRef}>
<table className="table sticky-table-header modal-sticky-table-header">
Expand Down Expand Up @@ -1929,7 +1937,7 @@ function NodeDetailModal({
</>
)}

{detailTab === 'routes' && (
{detailsLoaded && detailTab === 'routes' && (
<>
<div className="detail-table-wrapper" ref={routeTableWrapperRef}>
<table className="table sticky-table-header modal-sticky-table-header">
Expand Down Expand Up @@ -2037,7 +2045,7 @@ function NodeDetailModal({
</>
)}

{detailTab === 'bpf' && (
{detailsLoaded && detailTab === 'bpf' && (
<>
<div className="detail-table-wrapper">
<table className="table sticky-table-header modal-sticky-table-header">
Expand Down
13 changes: 6 additions & 7 deletions frontend/src/components/nodes/NodesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
CloseXIcon,
MagnifyPlusIcon,
TableFilterButton,
formatDateAndAge,
getCountColor,
getGatewayPoolBadgeTone,
uiDiag,
Expand Down Expand Up @@ -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 (
<span
className={`live-indicator${viaAPIServer ? ' warning' : ''}`}
title={viaAPIServer ? 'Streaming via API server fallback WebSocket' : 'Streaming via WebSocket'}
title={`${viaAPIServer ? 'Streaming via API server fallback WebSocket' : 'Streaming via WebSocket'}${updated ? `; last received ${updated.absolute}` : ''}`}
>
<span className="live-indicator-dot" aria-hidden="true"></span>
Live
Live{updated ? ` (${updated.age})` : ''}
</span>
);
}
return <span>{src || '-'}</span>;
return <span title={updated ? `Last received ${updated.absolute} (${src || 'unknown source'})` : undefined}>{updated?.age || src || '-'}</span>;
}
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -10,6 +11,7 @@ type NodeDetailTabsHeaderProps = {
};

function NodeDetailTabsHeader({
detailsLoaded = true,
detailTab,
routesValidationSummary,
bpfEntryCount,
Expand All @@ -30,7 +32,7 @@ function NodeDetailTabsHeader({
onClick={() => onDetailTabChange('routes')}
>
Routes
{routesValidationSummary.mismatch > 0 && (
{detailsLoaded && routesValidationSummary.mismatch > 0 && (
<span
className="tab-warning-icon route-presence route-presence-warning"
title={`${routesValidationSummary.mismatch} mismatched route next hop(s)`}
Expand All @@ -39,7 +41,7 @@ function NodeDetailTabsHeader({
</span>
)}
</button>
{bpfEntryCount > 0 && (
{(!detailsLoaded || bpfEntryCount > 0) && (
<button
className={`tab-button ${detailTab === 'bpf' ? 'active' : ''}`}
onClick={() => onDetailTabChange('bpf')}
Expand All @@ -49,7 +51,7 @@ function NodeDetailTabsHeader({
)}
</div>
<div className="detail-tabs-controls">
{detailTab !== 'peerings' && detailTab !== 'bpf' && (
{detailsLoaded && detailTab !== 'peerings' && detailTab !== 'bpf' && (
<>
{(() => {
const summary = routesValidationSummary;
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/state/nodeMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation.
// SPDX-License-Identifier: Apache-2.0

import type { NodeStatus, NodeSummary } from '../types';

export function nodeForDetailView(name: string, summary?: NodeSummary, details?: NodeStatus): NodeStatus {
const info = { ...details?.nodeInfo, ...summary?.nodeInfo };
return {
...details,
nodeInfo: {
...info,
name: summary?.name ?? info.name ?? name,
siteName: summary?.siteName ?? info.siteName,
isGateway: summary?.isGateway ?? info.isGateway,
k8sReady: summary?.k8sReady ?? info.k8sReady,
},
lastPushTime: summary?.lastPushTime ?? details?.lastPushTime,
statusSource: summary?.statusSource ?? details?.statusSource,
fetchError: summary ? summary.fetchError : details?.fetchError,
};
}
22 changes: 19 additions & 3 deletions frontend/tests/fixtures/nodeDetails.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,30 @@

function Harness() {
const [selected, select] = useState(null);
const [tab, selectTab] = useState('peerings');
const [summary, updateSummary] = useState(() => ({
name: 'node-a', siteName: 'site-a', k8sReady: 'Ready',
cniStatus: 'Healthy', cniTone: 'success', statusSource: 'ws',
lastPushTime: new Date().toISOString(),
nodeInfo: {
internalIPs: ['192.0.2.10'], podCIDRs: ['10.1.0.0/24'],
kernel: 'summary-kernel', buildInfo: { version: 'summary-build' },
},
}));
const { detail, load } = useNodeDetails(selected);
return React.createElement(React.Fragment, null,
React.createElement('button', { onClick: () => select('node-a') }, 'Open node'),
React.createElement('button', {
onClick: () => updateSummary((previous) => ({
...previous, cniStatus: 'Route mismatch', cniTone: 'warning',
nodeInfo: { ...previous.nodeInfo, kernel: 'updated-summary-kernel' },
})),
}, 'Update summary'),
React.createElement(NodeDetailDialog, {
nodeName: selected, detail, onLoad: load,
nodeName: selected, summary, detail, onLoad: load,
allNodeNames: ['node-a'], gatewayByNode: new Map(),
theme: 'light', detailTab: 'peerings',
onDetailTabChange: () => {}, onSelectNode: select,
theme: 'light', detailTab: tab,
onDetailTabChange: selectTab, onSelectNode: select,
onClose: () => select(null),
}),
);
Expand Down
Loading