feat(fleet): add aggregate masthead and local-vs-remote topology (#677)

Introduce a status masthead above the node grid summarising fleet-wide
CPU, memory, container, and alert counts with a coloured rail that
reflects overall health. Pin the local node at the top of the grid with
a cyan accent rail and a Local badge so it is never confused with a
remote. Add a Topology view toggle that plots the local node on the
left with remotes radiating out, and colour-codes connector lines by
link health.
This commit is contained in:
Anso
2026-04-18 02:06:56 -04:00
committed by GitHub
parent 748ba46669
commit c3b06f4b13
6 changed files with 553 additions and 100 deletions
+117 -84
View File
@@ -1,10 +1,12 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity,
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle,
Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2, Check,
CircleCheck, CircleAlert, Globe, Monitor, X,
CircleCheck, CircleAlert, Globe, Monitor, X, LayoutGrid, Network,
} from 'lucide-react';
import { FleetMasthead } from './fleet/FleetMasthead';
import { FleetTopology } from './fleet/FleetTopology';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
@@ -149,25 +151,6 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
);
}
function StatCard({ icon: Icon, label, value, sub, alert }: {
icon: React.ElementType;
label: string;
value: string;
sub?: string;
alert?: boolean;
}) {
return (
<div className={`rounded-lg border bg-card text-card-foreground shadow-card-bevel p-4 transition-colors ${alert ? 'border-destructive/30 bg-destructive/5' : 'border-card-border border-t-card-border-top hover:border-t-card-border-hover'}`}>
<div className="flex items-center gap-2 mb-2">
<Icon className={`w-4 h-4 ${alert ? 'text-destructive' : 'text-stat-icon'}`} />
<span className="text-xs text-stat-title">{label}</span>
</div>
<div className={`text-2xl font-medium tabular-nums tracking-tight ${alert ? 'text-destructive/70' : 'text-stat-value'}`}>{value}</div>
{sub && <p className="text-xs text-stat-subtitle mt-1">{sub}</p>}
</div>
);
}
function ContainerRow({ container, nodeId, onNavigate }: {
container: StackContainer;
nodeId: number;
@@ -444,6 +427,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
const [loadingStacks, setLoadingStacks] = useState(false);
const isOnline = node.status === 'online';
const isLocal = node.type === 'local';
const formattedVersion = formatVersion(updateStatus?.version);
const formattedLatest = formatVersion(updateStatus?.latestVersion);
const cpuPercent = getNodeCpu(node);
@@ -473,10 +457,19 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
}
};
const localRailClasses = isLocal
? 'relative overflow-hidden ring-1 ring-brand/30 before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-brand before:rounded-l-xl after:pointer-events-none after:absolute after:inset-0 after:bg-gradient-to-r after:from-brand/[0.06] after:via-transparent after:to-transparent'
: '';
return (
<div className={`rounded-xl border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover ${isOnline ? '' : 'opacity-60'}`}>
<div className={`rounded-xl border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover ${localRailClasses} ${isOnline ? '' : 'opacity-60'}`}>
{/* Card Header */}
<div className="p-4 pb-3">
<div className="relative p-4 pb-3">
{isLocal && (
<span className="absolute top-3 right-3 font-mono text-[9px] uppercase tracking-[0.22em] text-brand">
Local
</span>
)}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${isOnline ? 'bg-success-muted' : 'bg-muted'}`}>
@@ -658,6 +651,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'topology'>('grid');
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
const [fleetLabels, setFleetLabels] = useState<StackLabel[]>([]);
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
@@ -689,6 +684,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const res = await apiFetch('/fleet/overview', { localOnly: true });
if (res.ok) {
setNodes(await res.json());
setLastSyncAt(Date.now());
}
} catch (error) {
console.error('Failed to fetch fleet overview:', error);
@@ -830,7 +826,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
return () => { clearInterval(overviewInterval); clearInterval(updateInterval); };
}, [isPaid, fetchOverview, fetchUpdateStatus]);
// Fast poll (5s) when any node is actively updating — uses ref to avoid interval thrashing
// Fast poll (5s) when any node is actively updating. Uses ref to avoid interval thrashing.
const hasUpdatingRef = useRef(false);
useEffect(() => {
hasUpdatingRef.current = updateStatuses.some(s => s.updateStatus === 'updating');
@@ -852,19 +848,22 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const onlineCount = onlineNodes.length;
const totalContainers = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0);
const totalContainersAll = nodes.reduce((sum, n) => sum + (n.stats?.total ?? 0), 0);
const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0);
const criticalCount = onlineNodes.filter(isCritical).length;
const avgCpu = onlineNodes.length > 0
? (onlineNodes.reduce((sum, n) => sum + getNodeCpu(n), 0) / onlineNodes.length).toFixed(1)
: '0';
const avgCpuNum = onlineNodes.length > 0
? onlineNodes.reduce((sum, n) => sum + getNodeCpu(n), 0) / onlineNodes.length
: 0;
const worstCpuNode = onlineNodes.length > 0
? onlineNodes.reduce((worst, n) => getNodeCpu(n) > getNodeCpu(worst) ? n : worst, onlineNodes[0])
: null;
const worstCpu = worstCpuNode
? { name: worstCpuNode.name, percent: getNodeCpu(worstCpuNode) }
: null;
const totalMemUsed = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.used ?? 0), 0);
const totalMemTotal = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.total ?? 0), 0);
const updatableRemoteCount = useMemo(
() => updateStatuses.filter(s => s.updateAvailable && !s.updateStatus && s.type === 'remote').length,
[updateStatuses]
@@ -938,15 +937,56 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
return filtered;
}, [nodes, searchQuery, isPaid, prefs, labelFilters, fleetStackLabelMap]);
const localNode = useMemo(() => processedNodes.find(n => n.type === 'local') ?? null, [processedNodes]);
const remoteNodes = useMemo(() => processedNodes.filter(n => n.type !== 'local'), [processedNodes]);
const topologyNodes = useMemo(() => processedNodes.map(n => ({
id: n.id,
name: n.name,
type: n.type,
status: n.status,
cpuPercent: getNodeCpu(n),
memPercent: getNodeMem(n),
critical: n.status === 'online' && isCritical(n),
})), [processedNodes]);
return (
<div className="h-full overflow-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-medium tracking-tight">Fleet Overview</h1>
<p className="text-sm text-muted-foreground mt-1">
{loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`}
</p>
<FleetMasthead
nodeCount={nodes.length}
onlineCount={onlineCount}
criticalCount={criticalCount}
totalCpuPercent={avgCpuNum}
worstCpu={worstCpu}
totalMemUsed={totalMemUsed}
totalMemTotal={totalMemTotal}
activeContainers={totalContainers}
totalContainers={totalContainersAll}
lastSyncAt={lastSyncAt}
loading={loading}
/>
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-1 rounded-md border border-card-border bg-card p-0.5 shadow-card-bevel">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5 gap-1.5"
onClick={() => setViewMode('grid')}
aria-pressed={viewMode === 'grid'}
>
<LayoutGrid className="w-3.5 h-3.5" strokeWidth={1.5} />
Grid
</Button>
<Button
variant={viewMode === 'topology' ? 'default' : 'ghost'}
size="sm"
className="h-7 text-xs px-2.5 gap-1.5"
onClick={() => setViewMode('topology')}
aria-pressed={viewMode === 'topology'}
>
<Network className="w-3.5 h-3.5" strokeWidth={1.5} />
Topology
</Button>
</div>
<div className="flex items-center gap-2">
{isPaid && (
@@ -1026,39 +1066,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{/* Fleet Content */}
{!loading && nodes.length > 0 && (
<>
{/* Paid: Fleet Health Summary Cards */}
{isPaid && onlineNodes.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<StatCard
icon={Box}
label="Containers"
value={`${totalContainers}`}
sub={`${totalContainersAll} total across fleet`}
/>
<StatCard
icon={Activity}
label="Fleet CPU"
value={`${avgCpu}%`}
sub={worstCpuNode ? `Peak: ${worstCpuNode.name} (${worstCpuNode.systemStats?.cpu.usage}%)` : undefined}
/>
<StatCard
icon={MemoryStick}
label="Fleet Memory"
value={formatBytes(totalMemUsed)}
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined}
/>
<StatCard
icon={AlertTriangle}
label="Alerts"
value={`${criticalCount}`}
sub={criticalCount > 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'}
alert={criticalCount > 0}
/>
</div>
)}
{/* Paid: Search, Sort & Filter Toolbar */}
{isPaid && (
{isPaid && viewMode === 'grid' && (
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm">
@@ -1158,22 +1167,46 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</div>
)}
{/* Node Grid */}
{processedNodes.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{processedNodes.map(node => (
<NodeCard
key={node.id}
node={node}
onNavigate={onNavigateToNode}
labelMap={fleetStackLabelMap}
updateStatus={updateStatusMap.get(node.id)}
onUpdate={isPaid ? triggerNodeUpdate : undefined}
updatingNodeId={updatingNodeId}
onRetryUpdate={isPaid ? retryNodeUpdate : undefined}
onDismissUpdate={isPaid ? dismissNodeUpdate : undefined}
/>
))}
{/* Node Grid or Topology */}
{viewMode === 'topology' && processedNodes.length > 0 ? (
<FleetTopology
nodes={topologyNodes}
onNodeClick={(id) => onNavigateToNode(id, '')}
/>
) : processedNodes.length > 0 ? (
<div className="space-y-4">
{localNode && (
<div className="grid grid-cols-1">
<NodeCard
key={localNode.id}
node={localNode}
onNavigate={onNavigateToNode}
labelMap={fleetStackLabelMap}
updateStatus={updateStatusMap.get(localNode.id)}
onUpdate={isPaid ? triggerNodeUpdate : undefined}
updatingNodeId={updatingNodeId}
onRetryUpdate={isPaid ? retryNodeUpdate : undefined}
onDismissUpdate={isPaid ? dismissNodeUpdate : undefined}
/>
</div>
)}
{remoteNodes.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{remoteNodes.map(node => (
<NodeCard
key={node.id}
node={node}
onNavigate={onNavigateToNode}
labelMap={fleetStackLabelMap}
updateStatus={updateStatusMap.get(node.id)}
onUpdate={isPaid ? triggerNodeUpdate : undefined}
updatingNodeId={updatingNodeId}
onRetryUpdate={isPaid ? retryNodeUpdate : undefined}
onDismissUpdate={isPaid ? dismissNodeUpdate : undefined}
/>
))}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
@@ -1232,7 +1265,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
)}
</Tabs>
{/* Reconnecting overlay shown when local node is updating */}
{/* Reconnecting overlay shown when local node is updating */}
{reconnecting && <ReconnectingOverlay preUpdateStartedAt={preUpdateStartedAt} />}
{/* Node Updates modal */}
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from 'react';
import { Bell } from 'lucide-react';
import {
CursorProvider,
Cursor,
CursorContainer,
CursorFollow,
} from '@/components/animate-ui/primitives/animate/cursor';
type FleetHealth = 'healthy' | 'degraded' | 'critical';
interface FleetMastheadProps {
nodeCount: number;
onlineCount: number;
criticalCount: number;
totalCpuPercent: number;
worstCpu: { name: string; percent: number } | null;
totalMemUsed: number;
totalMemTotal: number;
activeContainers: number;
totalContainers: number;
lastSyncAt: number | null;
loading: boolean;
}
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '0 GiB';
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
}
function formatAgo(ms: number): string {
const clamped = Math.max(0, ms);
if (clamped < 60_000) return `${Math.round(clamped / 1000)}s`;
if (clamped < 3_600_000) return `${Math.round(clamped / 60_000)}m`;
return `${Math.round(clamped / 3_600_000)}h`;
}
function useTicker(intervalMs: number): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
const healthConfig: Record<FleetHealth, { label: string; dotClass: string; textClass: string; railClass: string; tintClass: string }> = {
healthy: {
label: 'The fleet',
dotClass: 'bg-success shadow-[0_0_0_3px_color-mix(in_oklch,var(--success)_20%,transparent)]',
textClass: 'text-stat-value',
railClass: 'bg-brand',
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
},
degraded: {
label: 'The fleet',
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
textClass: 'text-warning',
railClass: 'bg-warning',
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
},
critical: {
label: 'The fleet',
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
textClass: 'text-destructive',
railClass: 'bg-destructive',
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
},
};
export function FleetMasthead({
nodeCount,
onlineCount,
criticalCount,
totalCpuPercent,
worstCpu,
totalMemUsed,
totalMemTotal,
activeContainers,
totalContainers,
lastSyncAt,
loading,
}: FleetMastheadProps) {
const level: FleetHealth = useMemo(() => {
if (criticalCount > 0) return 'critical';
if (onlineCount < nodeCount) return 'degraded';
return 'healthy';
}, [criticalCount, onlineCount, nodeCount]);
const config = healthConfig[level];
const now = useTicker(1000);
const offlineCount = Math.max(0, nodeCount - onlineCount);
const reasons: string[] = [];
if (offlineCount > 0) reasons.push(`${offlineCount} offline`);
if (criticalCount > 0) reasons.push(`${criticalCount} critical`);
const lastSyncLabel = loading
? 'syncing…'
: lastSyncAt
? `last sync ${formatAgo(now - lastSyncAt)}`
: 'no sync yet';
const metaLine = `${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'} · ${onlineCount} online · ${lastSyncLabel}`;
const reasonsLine = reasons.join(' · ');
const cpuTone = totalCpuPercent >= 80 ? 'warn' : 'value';
const memPercent = totalMemTotal > 0 ? (totalMemUsed / totalMemTotal) * 100 : 0;
return (
<div className="relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors mb-4">
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-r ${config.tintClass}`} />
<div className={`absolute inset-y-0 left-0 w-[3px] ${config.railClass}`} />
<div className="relative grid grid-cols-[auto_1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
<div className="flex items-center gap-4">
<span
aria-hidden="true"
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
/>
<div className="flex flex-col gap-1">
<span className={`font-display italic text-3xl leading-none tracking-tight ${config.textClass}`}>
{config.label}
</span>
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-stat-subtitle">
{metaLine}
</span>
{reasonsLine ? (
<span className="font-mono text-[11px] text-stat-subtitle/90">
{reasonsLine}
</span>
) : null}
</div>
</div>
<div className="hidden items-stretch justify-end gap-0 md:flex">
<StatTile
label="CPU"
value={`${totalCpuPercent.toFixed(0)}%`}
sub={worstCpu ? `peak ${worstCpu.name} ${worstCpu.percent.toFixed(0)}%` : undefined}
tone={cpuTone}
/>
<StatTile
label="MEM"
value={formatBytes(totalMemUsed)}
sub={totalMemTotal > 0 ? `of ${formatBytes(totalMemTotal)} · ${memPercent.toFixed(0)}%` : undefined}
tone="value"
divider
/>
<CursorProvider>
<CursorContainer>
<StatTile
label="CONTAINERS"
value={`${activeContainers}`}
sub={`of ${totalContainers} total`}
tone="value"
divider
/>
</CursorContainer>
<Cursor>
<span className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
<CursorFollow
side="bottom"
sideOffset={8}
align="center"
transition={{ stiffness: 400, damping: 40, bounce: 0 }}
>
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-3 py-2 shadow-md">
<div className="flex items-center gap-3 font-mono text-xs tabular-nums">
<span className="text-stat-value">
{activeContainers}
<span className="ml-1 font-sans text-stat-subtitle">running</span>
</span>
<span className="text-stat-icon">·</span>
<span className="text-stat-value">
{totalContainers}
<span className="ml-1 font-sans text-stat-subtitle">total</span>
</span>
</div>
</div>
</CursorFollow>
</CursorProvider>
</div>
<div className="flex items-center gap-2 pl-4">
<Bell
className={`h-3.5 w-3.5 ${criticalCount > 0 ? 'text-destructive' : 'text-stat-icon'}`}
strokeWidth={1.5}
/>
<span
className={`font-mono text-sm tabular-nums ${criticalCount > 0 ? 'text-destructive' : 'text-stat-subtitle'}`}
>
{criticalCount}
</span>
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-stat-subtitle">
{criticalCount === 1 ? 'alert' : 'alerts'}
</span>
</div>
</div>
</div>
);
}
function StatTile({
label,
value,
sub,
tone,
divider,
}: {
label: string;
value: string;
sub?: string;
tone: 'value' | 'warn';
divider?: boolean;
}) {
return (
<div className={`flex flex-col gap-1 px-5 ${divider ? 'border-l border-border/60' : ''}`}>
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{label}
</span>
<span
className={`font-mono tabular-nums text-xl leading-none ${tone === 'warn' ? 'text-warning' : 'text-stat-value'}`}
>
{value}
</span>
{sub ? (
<span className="font-mono text-[10px] text-stat-subtitle/80">{sub}</span>
) : null}
</div>
);
}
@@ -0,0 +1,172 @@
import { useMemo } from 'react';
import { Server } from 'lucide-react';
interface TopologyNode {
id: number;
name: string;
type: 'local' | 'remote';
status: 'online' | 'offline' | 'unknown';
cpuPercent: number;
memPercent: number;
critical: boolean;
}
interface FleetTopologyProps {
nodes: TopologyNode[];
onNodeClick?: (nodeId: number) => void;
}
interface Positioned {
node: TopologyNode;
x: number;
y: number;
}
const LOCAL_X = 140;
const LOCAL_Y = 260;
const REMOTE_X_START = 460;
const REMOTE_X_STEP = 220;
const REMOTE_Y_AMPLITUDE = 160;
const CANVAS_WIDTH = 1180;
const CANVAS_HEIGHT = 520;
function layoutRemotes(remotes: TopologyNode[]): Positioned[] {
if (remotes.length === 0) return [];
const positioned: Positioned[] = [];
const cols = Math.min(3, Math.max(1, Math.ceil(remotes.length / 3)));
const perCol = Math.ceil(remotes.length / cols);
for (let i = 0; i < remotes.length; i += 1) {
const col = Math.floor(i / perCol);
const row = i % perCol;
const x = REMOTE_X_START + col * REMOTE_X_STEP;
const yCenter = LOCAL_Y;
const offset = perCol === 1
? 0
: (row - (perCol - 1) / 2) * (REMOTE_Y_AMPLITUDE / Math.max(1, perCol - 1)) * 2;
positioned.push({ node: remotes[i], x, y: yCenter + offset });
}
return positioned;
}
function linkClass(node: TopologyNode): string {
if (node.status !== 'online') return 'stroke-destructive/40';
if (node.critical) return 'stroke-warning/60';
return 'stroke-brand/40';
}
function dotClass(node: TopologyNode): string {
if (node.status !== 'online') return 'bg-destructive';
if (node.critical) return 'bg-warning';
return 'bg-success';
}
export function FleetTopology({ nodes, onNodeClick }: FleetTopologyProps) {
const local = useMemo(() => nodes.find(n => n.type === 'local') ?? null, [nodes]);
const remotes = useMemo(() => layoutRemotes(nodes.filter(n => n.type === 'remote')), [nodes]);
if (!local && remotes.length === 0) {
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-10 text-center">
<p className="text-sm text-stat-subtitle">No nodes to plot.</p>
</div>
);
}
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
<div className="relative overflow-hidden rounded-md" style={{ height: CANVAS_HEIGHT }}>
<svg
viewBox={`0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`}
className="absolute inset-0 h-full w-full"
preserveAspectRatio="xMidYMid meet"
>
{local
? remotes.map(r => (
<line
key={`link-${r.node.id}`}
x1={LOCAL_X + 48}
y1={LOCAL_Y}
x2={r.x}
y2={r.y}
strokeWidth={1}
strokeDasharray={r.node.status === 'online' ? undefined : '4 4'}
className={linkClass(r.node)}
/>
))
: null}
</svg>
{local ? (
<NodeChip
node={local}
x={LOCAL_X}
y={LOCAL_Y}
size="lg"
canvasWidth={CANVAS_WIDTH}
canvasHeight={CANVAS_HEIGHT}
onClick={onNodeClick}
/>
) : null}
{remotes.map(r => (
<NodeChip
key={r.node.id}
node={r.node}
x={r.x}
y={r.y}
size="md"
canvasWidth={CANVAS_WIDTH}
canvasHeight={CANVAS_HEIGHT}
onClick={onNodeClick}
/>
))}
</div>
</div>
);
}
interface NodeChipProps {
node: TopologyNode;
x: number;
y: number;
size: 'md' | 'lg';
canvasWidth: number;
canvasHeight: number;
onClick?: (nodeId: number) => void;
}
function NodeChip({ node, x, y, size, canvasWidth, canvasHeight, onClick }: NodeChipProps) {
const isLg = size === 'lg';
const width = isLg ? 200 : 180;
const height = isLg ? 96 : 80;
const isLocal = node.type === 'local';
return (
<button
type="button"
onClick={() => onClick?.(node.id)}
className={`absolute flex flex-col items-start gap-1 rounded-lg border border-card-border border-t-card-border-top bg-card px-3 py-2 text-left shadow-card-bevel transition-colors hover:border-t-card-border-hover ${isLocal ? 'ring-1 ring-brand/40' : ''}`}
style={{
left: `${((x - width / 2) / canvasWidth) * 100}%`,
top: `${((y - height / 2) / canvasHeight) * 100}%`,
width,
height,
}}
>
<div className="flex items-center gap-2">
<span aria-hidden="true" className={`h-2 w-2 rounded-full ${dotClass(node)}`} />
<Server className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
<span className="font-mono text-xs text-stat-value truncate">{node.name}</span>
{isLocal ? (
<span className="ml-auto font-mono text-[9px] uppercase tracking-[0.22em] text-brand">Local</span>
) : null}
</div>
<div className="flex items-baseline gap-2 font-mono text-[11px] tabular-nums text-stat-subtitle">
<span>CPU {node.cpuPercent.toFixed(0)}%</span>
<span className="text-stat-icon">·</span>
<span>MEM {node.memPercent.toFixed(0)}%</span>
</div>
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle/80">
{node.status === 'online' ? (node.critical ? 'critical' : 'online') : 'offline'}
</div>
</button>
);
}