mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user