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
52 changes: 52 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 11 additions & 24 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -489,7 +477,6 @@ export default function App() {
sites={sites}
siteCounts={siteCounts}
nodeSummaries={nodeSummaries}
nodes={nodes}
/>
</div>
<div>
Expand Down
57 changes: 5 additions & 52 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ async function fetchNodeDetails(path: string, options: RequestInit): Promise<Nod
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})`);
// Lifecycle failures intentionally use 410/404/503; retain their state so
// expiry isn't presented as a generic network failure.
if (!result?.nodeName || !['expired', 'unavailable', 'retryable'].includes(result.state)) {
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');
Expand Down Expand Up @@ -73,57 +77,6 @@ export async function fetchClusterStatus(signal?: AbortSignal): Promise<ClusterS
}


export function mergeDelta(current: ClusterStatus | null, delta: ClusterStatusDelta): ClusterStatus {
if (!current) {
return delta as ClusterStatus;
}
const merged: ClusterStatus = { ...current };
merged.timestamp = delta.timestamp ?? merged.timestamp;
merged.nodeCount = delta.nodeCount ?? merged.nodeCount;
merged.siteCount = delta.siteCount ?? merged.siteCount;
merged.azureTenantId = delta.azureTenantId ?? merged.azureTenantId;
merged.buildInfo = delta.buildInfo ?? merged.buildInfo;
merged.leaderInfo = delta.leaderInfo ?? merged.leaderInfo;
merged.errors = delta.errors ?? merged.errors;
merged.warnings = delta.warnings ?? merged.warnings;
merged.problems = delta.problems ?? merged.problems;
merged.sites = delta.sites ?? merged.sites;
merged.gatewayPools = delta.gatewayPools ?? merged.gatewayPools;
merged.peerings = delta.peerings ?? merged.peerings;
merged.pullEnabled = delta.pullEnabled ?? merged.pullEnabled;

if (delta.nodes) {
// Allow full-node snapshots to replace local state directly.
merged.nodes = delta.nodes;
} else {
const nodeMap: Record<string, NodeStatus> = {};
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,
Expand Down
31 changes: 9 additions & 22 deletions frontend/src/components/network/SitesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,13 +19,11 @@ type SortKey = 'name' | 'workers' | 'gateways' | 'state';
function SitesCard({
sites,
siteCounts,
nodeSummaries,
nodes
nodeSummaries
}: {
sites: SiteStatus[];
siteCounts: Map<string, { online: number; total: number }>;
nodeSummaries: NodeSummary[];
nodes: NodeStatus[];
}) {
const [sort, setSort] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({ key: 'name', direction: 'asc' });

Expand All @@ -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++;
}
}

Expand All @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/status/StatusJsonModal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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));
Expand All @@ -52,7 +53,7 @@ function StatusJsonModal({
} else {
setSnapshotStatus(null);
}
return () => { cancelled = true; };
return () => { cancelled = true; abort.abort(); };
}, [open]);

const statusJsonValue = useMemo<JsonValue>(() => {
Expand Down
Loading