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
+33 -16
View File
@@ -6,17 +6,45 @@ description: Monitor all your nodes from a single dashboard with real-time healt
The **Fleet** tab gives you a bird's-eye view of every node in your Sencho deployment, local and remote, on one screen. It is available to all tiers, with advanced features unlocked by Skipper and Admiral.
<Frame>
<img src="/images/fleet-view/fleet-overview.png" alt="Fleet Overview showing health summary cards, toolbar, node cards, and tabs" />
<img src="/images/fleet-view/fleet-overview.png" alt="Fleet Overview with the fleet masthead, grid/topology toggle, and pinned local node" />
</Frame>
## Page layout
The Fleet page is divided into two tabs:
### Fleet masthead
- **Overview** - the main monitoring view described on this page
- **Snapshots** - fleet-wide backup snapshots (covered in [Fleet Backups](/features/fleet-backups))
The top of the page is a status masthead that summarises the state of the entire fleet at a glance:
The header shows **Fleet Overview** with a subtitle summarising the current state (e.g. "2 of 2 nodes online, 31 containers, 21 stacks"). Two buttons sit in the top-right corner:
- **Status word** in italic display type: **The fleet** with a pulsing coloured rail (green when all nodes are online and healthy, amber when any node is offline, rose when any node is critical)
- **Meta line** showing node count, online count, and last sync time (e.g. "2 nodes · 1 online · last sync 7s")
- **Reasons** line appears when the fleet is degraded, summarising what's wrong ("1 offline · 1 critical")
- **Stats cluster** with three tiles:
- **CPU**: average across online nodes, with the peak node and percentage called out below
- **MEM**: total RAM used across the fleet, with total capacity and percentage
- **CONTAINERS**: active running count; hover the tile to see a breakdown of running vs total
- **Alerts** indicator on the right shows the current critical count, highlighted in rose when above zero
### Grid / Topology toggle
Below the masthead, switch between two layouts:
- **Grid** (default): one card per node. The local node is pinned at the top with a cyan accent rail and a ★ Local badge so it is never confused with a remote.
- **Topology**: a connection diagram with the local node on the left and remotes radiating to the right. Connector lines colour by link health (cyan when online, amber when critical, dashed rose when offline), and each node chip shows its status dot, CPU and memory readings. Click a node to jump to its details.
<Frame>
<img src="/images/fleet-view/fleet-topology.png" alt="Fleet Topology view showing the local node on the left with a dashed connector to an offline remote" />
</Frame>
### Tabs
The Fleet page has two tabs:
- **Overview**: the monitoring view described on this page
- **Snapshots**: fleet-wide backup snapshots (covered in [Fleet Backups](/features/fleet-backups))
### Action buttons
Two buttons sit in the top-right corner:
| Button | What it does |
|--------|--------------|
@@ -61,17 +89,6 @@ Click the **Refresh** button in the top-right to re-fetch data from all nodes. T
The features below require a Skipper or Admiral license. Community users see an upgrade prompt in place of these controls.
</Note>
### Fleet health summary cards
Four cards appear above the node grid, aggregating data across all online nodes:
| Card | What it shows |
|------|---------------|
| **Containers** | Total running containers, with fleet-wide total in subtitle |
| **Fleet CPU** | Average CPU across all online nodes, plus which node has the highest load |
| **Fleet Memory** | Total RAM used / total available across the fleet |
| **Alerts** | Count of nodes with critical resource usage (CPU or disk above 90%). Card turns red when any exist |
### Auto-refresh
Fleet data automatically refreshes every 30 seconds. A subtle indicator at the bottom of the page confirms this is active. When a node update is in progress, the refresh rate increases to every 5 seconds so you can watch status changes in near real-time.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+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>
);
}