diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 290fa084..26ae37ed 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -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. - Fleet Overview showing health summary cards, toolbar, node cards, and tabs + Fleet Overview with the fleet masthead, grid/topology toggle, and pinned local node ## 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. + + + Fleet Topology view showing the local node on the left with a dashed connector to an offline remote + + +### 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. -### 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. diff --git a/docs/images/fleet-view/fleet-overview.png b/docs/images/fleet-view/fleet-overview.png index ee122a91..a1152422 100644 Binary files a/docs/images/fleet-view/fleet-overview.png and b/docs/images/fleet-view/fleet-overview.png differ diff --git a/docs/images/fleet-view/fleet-topology.png b/docs/images/fleet-view/fleet-topology.png new file mode 100644 index 00000000..16c901ed Binary files /dev/null and b/docs/images/fleet-view/fleet-topology.png differ diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 44c5f393..0601443f 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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 ( -
-
- - {label} -
-
{value}
- {sub &&

{sub}

} -
- ); -} - 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 ( -
+
{/* Card Header */} -
+
+ {isLocal && ( + + ★ Local + + )}
@@ -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(null); + const [viewMode, setViewMode] = useState<'grid' | 'topology'>('grid'); const [prefs, setPrefs] = useState(loadPreferences); const [fleetLabels, setFleetLabels] = useState([]); const [fleetStackLabelMap, setFleetStackLabelMap] = useState>({}); @@ -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 (
- {/* Header */} -
-
-

Fleet Overview

-

- {loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`} -

+ + +
+
+ +
{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 && ( -
- - - 0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined} - /> - 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'} - alert={criticalCount > 0} - /> -
- )} - {/* Paid: Search, Sort & Filter Toolbar */} - {isPaid && ( + {isPaid && viewMode === 'grid' && (
{/* Search */}
@@ -1158,22 +1167,46 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
)} - {/* Node Grid */} - {processedNodes.length > 0 ? ( -
- {processedNodes.map(node => ( - - ))} + {/* Node Grid or Topology */} + {viewMode === 'topology' && processedNodes.length > 0 ? ( + onNavigateToNode(id, '')} + /> + ) : processedNodes.length > 0 ? ( +
+ {localNode && ( +
+ +
+ )} + {remoteNodes.length > 0 && ( +
+ {remoteNodes.map(node => ( + + ))} +
+ )}
) : (
@@ -1232,7 +1265,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} - {/* Reconnecting overlay — shown when local node is updating */} + {/* Reconnecting overlay shown when local node is updating */} {reconnecting && } {/* Node Updates modal */} diff --git a/frontend/src/components/fleet/FleetMasthead.tsx b/frontend/src/components/fleet/FleetMasthead.tsx new file mode 100644 index 00000000..2d7fae7c --- /dev/null +++ b/frontend/src/components/fleet/FleetMasthead.tsx @@ -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 = { + 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 ( +
+
+
+
+
+
+ +
+ + 0 ? `of ${formatBytes(totalMemTotal)} · ${memPercent.toFixed(0)}%` : undefined} + tone="value" + divider + /> + + + + + + + + +
+
+ + {activeContainers} + running + + · + + {totalContainers} + total + +
+
+
+
+
+ +
+ 0 ? 'text-destructive' : 'text-stat-icon'}`} + strokeWidth={1.5} + /> + 0 ? 'text-destructive' : 'text-stat-subtitle'}`} + > + {criticalCount} + + + {criticalCount === 1 ? 'alert' : 'alerts'} + +
+
+
+ ); +} + +function StatTile({ + label, + value, + sub, + tone, + divider, +}: { + label: string; + value: string; + sub?: string; + tone: 'value' | 'warn'; + divider?: boolean; +}) { + return ( +
+ + {label} + + + {value} + + {sub ? ( + {sub} + ) : null} +
+ ); +} diff --git a/frontend/src/components/fleet/FleetTopology.tsx b/frontend/src/components/fleet/FleetTopology.tsx new file mode 100644 index 00000000..e7007131 --- /dev/null +++ b/frontend/src/components/fleet/FleetTopology.tsx @@ -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 ( +
+

No nodes to plot.

+
+ ); + } + + return ( +
+
+ + {local + ? remotes.map(r => ( + + )) + : null} + + + {local ? ( + + ) : null} + {remotes.map(r => ( + + ))} +
+
+ ); +} + +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 ( + + ); +}