Skip to content
Draft
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
33 changes: 32 additions & 1 deletion frontend/src/api.ts
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 { 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';
Expand All @@ -15,6 +15,37 @@ function buildControllerUrl(path: string): string {
return path;
}

async function fetchNodeDetails(path: string, options: RequestInit): Promise<NodeDetailResult> {
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<ClusterStatus> {
const url = buildControllerUrl('/status/json');
try {
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/hooks/useNodeDetails.ts
Original file line number Diff line number Diff line change
@@ -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<NodeDetails | null>(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,
};
}
164 changes: 164 additions & 0 deletions frontend/src/state/clusterSummary.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading