diff --git a/backend/src/index.ts b/backend/src/index.ts index afc61b3b..f6d77ee1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4376,7 +4376,7 @@ app.get('/api/stacks/statuses', async (req: Request, res: Response) => { const dockerController = DockerController.getInstance(req.nodeId); const bulkInfo = await dockerController.getBulkStackStatuses(stackNames); // Map back to filenames to match frontend expectations - const data: Record = {}; + const data: Record = {}; for (const stack of stacks) { const name = stack.replace(/\.(yml|yaml)$/, ''); data[stack] = bulkInfo[name] ?? { status: 'unknown' }; diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 90281637..8a4ddfe0 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -29,6 +29,8 @@ const IGNORE_PORTS = [1900, 53, 22]; export interface BulkStackInfo { status: 'running' | 'exited' | 'unknown'; mainPort?: number; + /** Unix seconds of the oldest running container (approximates stack uptime). */ + runningSince?: number; } export interface ClassifiedImage { @@ -660,6 +662,18 @@ class DockerController { if (container.State === 'running') { result[stackDir].status = 'running'; + // Track the oldest running container's creation time as a proxy for + // stack uptime. Docker's listContainers payload exposes Created (unix + // seconds) but not StartedAt; for compose stacks the gap is small + // enough to treat as uptime without paying for a per-container inspect. + const created = typeof container.Created === 'number' ? container.Created : undefined; + if (created !== undefined) { + const existing = result[stackDir].runningSince; + if (existing === undefined || created < existing) { + result[stackDir].runningSince = created; + } + } + // Detect main web port (first running container with a matchable port wins) if (result[stackDir].mainPort === undefined && Array.isArray(container.Ports) && container.Ports.length > 0) { const ports = container.Ports as { PrivatePort?: number; PublicPort?: number }[]; diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index e4447863..03d4ec78 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -6,53 +6,63 @@ description: Real-time system stats, stack health, historical metrics, and recen The **Home** tab is the first thing you see after logging in. It provides a live overview of your node's health, resource usage, stack status, and recent alert activity. - Sencho dashboard showing health status, resource gauges, stack health table, and historical charts + Sencho dashboard showing status masthead, unified gauge strip, stack health table, and historical charts -## Health status bar +## Status masthead -The top bar provides an at-a-glance health assessment for the active node. Sencho evaluates CPU, RAM, disk usage, exited containers, and unread error alerts to derive one of three states: +The masthead at the top of the dashboard is the single place to read the node's current condition. It carries: -| Status | Meaning | -|--------|---------| +- A **state word** (Healthy, Degraded, or Critical) set in the editorial display face so the reader sees it first. +- A **pulsing dot** that mirrors the state color: green when nominal, amber when degraded, rose when critical. +- A **meta line** with the active node, the number of nodes in the fleet, and the time since the last successful sync. +- A **reasons line** that names exactly which signals moved the state away from Healthy (for example, "CPU 84% · 2 exited · 3 unread errors"). No hovering required. +- Three quick stat tiles on the right: containers running, aggregate CPU, and memory in use. Hover the **Running** tile to see the managed / external / exited breakdown. +- An alerts counter pinned to the far right. + +Sencho derives the state from CPU, RAM, disk usage, exited containers, and unread error alerts: + +| State | Meaning | +|-------|---------| | **Healthy** | All systems nominal. No resources above warning thresholds, no unread errors. | | **Degraded** | At least one resource is above 80%, there are exited containers, or there are unread error alerts. | | **Critical** | At least one resource is above 90%, or there are exited containers combined with unread errors. | -The bar also shows the active node name, the number of running containers, and the current alert count. +## Unified gauge strip -## Resource gauges +A single rail of four tiles shows the numbers that change minute-to-minute: -Five compact cards display real-time host and container metrics: - -| Card | What it shows | +| Tile | What it shows | |------|---------------| -| **CPU** | Current CPU usage percentage, core count, and a color-coded gauge bar | -| **Memory** | RAM usage percentage, used/total in GB, and a gauge bar | -| **Disk** | Disk usage percentage, used/total for the primary mount, and a gauge bar | -| **Containers** | Active container count (hover to see managed vs. external breakdown) and exited count | -| **Network** | Current RX (receive) and TX (transmit) throughput in bytes/second | +| **CPU (hero)** | Current usage with a 10-minute sparkline, average for the window, and the peak value with its time offset | +| **Memory** | RAM usage with a compact bar and the exact used/total split | +| **Disk** | Mount usage with a compact bar and the exact used/total split | +| **Network** | Total throughput per second with the received/transmitted split and a live rhythm spark | -Gauge bars turn yellow at 80% usage and red at 90%. +The CPU tile's sparkline uses cyan as the data color and marks the peak in amber. Memory and disk bars turn amber at 80% and rose at 90%. -## Stack health table +## Stack health -A table listing every stack in your `COMPOSE_DIR` with live status and resource usage: +A mono table of every stack discovered in your `COMPOSE_DIR`, sorted by load so the stacks demanding attention sit at the top: | Column | Description | |--------|-------------| +| **State dot** | Green when healthy, amber when any container in the stack is pushing CPU above 80%, rose when one has exited or is above 90% | | **Stack** | Stack name (derived from the directory name) | -| **Status** | `UP` (running) or `DN` (exited) | +| **Host** | Active node this stack belongs to | +| **Up** | How long the oldest running container has been up, in compact units (s/m/h/d) | +| **CPU** | Latest aggregate CPU for the stack's containers | | **Memory** | Total memory allocated by the stack's containers | +| **CPU · 10m** | Per-stack sparkline of the last 10 minutes, tinted to match the row state | -Click any row to navigate directly to that stack's editor. Stacks are sorted with running stacks first, then alphabetically. If you have more than 8 stacks, the table paginates automatically. For fleet-wide CPU usage, see the **CPU** resource gauge card at the top of the dashboard and the **CPU Usage** historical chart below the table. +Warning rows take on a subtle amber wash; critical rows take on a rose wash. Click any row to jump to that stack's editor. If you have more than 8 stacks, the list paginates automatically. -## Historical metrics charts +## Historical charts Two area charts display time-series data sampled at one-minute intervals, retained for up to 24 hours: -- **CPU Usage** - normalized total CPU percentage across all managed containers over host cores -- **RAM Usage** - total memory allocated by managed containers, in GB +- **CPU** - normalized total CPU percentage across all managed containers over host cores, stroked in cyan with the peak highlighted in amber. +- **Memory** - total memory allocated by managed containers in GB. Hover over a data point to see the exact value at that moment. diff --git a/docs/images/dashboard/dashboard-overview.png b/docs/images/dashboard/dashboard-overview.png index 61114649..cf2d47fe 100644 Binary files a/docs/images/dashboard/dashboard-overview.png and b/docs/images/dashboard/dashboard-overview.png differ diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 97225280..156c19d6 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -15,9 +15,12 @@ interface HomeDashboardProps { onClearNotifications: () => void | Promise; } +const NOOP = () => {}; + export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) { const { activeNode, nodes } = useNodes(); const data = useDashboardData(); + const activeNodeName = activeNode?.name || 'Local'; return (
@@ -25,18 +28,24 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea stats={data.stats} systemStats={data.systemStats} notifications={notifications} - activeNodeName={activeNode?.name || 'Local'} + activeNodeName={activeNodeName} + nodeCount={data.nodeCount} + lastSyncAt={data.lastSyncAt} /> {})} + stackCpuSeries={data.stackCpuSeries} + activeNodeName={activeNodeName} + onNavigateToStack={onNavigateToStack ?? NOOP} /> !n.is_read && n.level === 'error').length; const reasons: string[] = []; - - if (cpu >= 90) reasons.push(`CPU at ${cpu.toFixed(1)}%`); - else if (cpu >= 80) reasons.push(`CPU at ${cpu.toFixed(1)}%`); - - if (ram >= 90) reasons.push(`RAM at ${ram.toFixed(1)}%`); - else if (ram >= 80) reasons.push(`RAM at ${ram.toFixed(1)}%`); - - if (disk >= 90) reasons.push(`Disk at ${disk.toFixed(1)}%`); - else if (disk >= 80) reasons.push(`Disk at ${disk.toFixed(1)}%`); - - if (stats.exited > 0) reasons.push(`${stats.exited} exited container${stats.exited !== 1 ? 's' : ''}`); - if (unreadErrors > 0) reasons.push(`${unreadErrors} unread error${unreadErrors !== 1 ? 's' : ''}`); + if (cpu >= 80) reasons.push(`CPU ${cpu.toFixed(0)}%`); + if (ram >= 80) reasons.push(`RAM ${ram.toFixed(0)}%`); + if (disk >= 80) reasons.push(`Disk ${disk.toFixed(0)}%`); + if (stats.exited > 0) reasons.push(`${stats.exited} exited`); + if (unreadErrors > 0) reasons.push(`${unreadErrors} unread ${unreadErrors === 1 ? 'error' : 'errors'}`); if (cpu >= 90 || ram >= 90 || disk >= 90 || (stats.exited > 0 && unreadErrors > 0)) { return { level: 'critical', reasons }; @@ -50,72 +44,184 @@ function deriveHealth(stats: Stats, systemStats: SystemStats | null, notificatio return { level: 'healthy', reasons: ['All systems nominal'] }; } -const healthConfig: Record = { - healthy: { label: 'Healthy', dotClass: 'bg-success', textClass: 'text-success' }, - degraded: { label: 'Degraded', dotClass: 'bg-warning animate-pulse', textClass: 'text-warning' }, - critical: { label: 'Critical', dotClass: 'bg-destructive animate-pulse', textClass: 'text-destructive' }, +const healthConfig: Record = { + healthy: { + label: 'Healthy', + 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: 'Degraded', + 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: 'Critical', + 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 HealthStatusBar({ stats, systemStats, notifications, activeNodeName }: HealthStatusBarProps) { +function formatGib(bytes: number): string { + 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; +} + +export function HealthStatusBar({ + stats, + systemStats, + notifications, + activeNodeName, + nodeCount, + lastSyncAt, +}: HealthStatusBarProps) { const { level, reasons } = useMemo( () => deriveHealth(stats, systemStats, notifications), - [stats, systemStats, notifications] + [stats, systemStats, notifications], ); const config = healthConfig[level]; + const now = useTicker(1000); const unreadAlerts = notifications.filter(n => !n.is_read).length; + const running = `${stats.active}/${stats.total}`; + const cpuLabel = systemStats ? `${parseFloat(systemStats.cpu.usage).toFixed(0)}%` : '--'; + const memLabel = systemStats ? formatGib(systemStats.memory.used) : '--'; + const lastSyncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…'; + const metaLine = `${activeNodeName} · ${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'} · ${lastSyncLabel}`; + const reasonsLine = reasons.join(' · '); return ( - -
- {/* Health badge */} -
-
- - -
- - {config.label} - - - -
- - -
-
- {reasons.map((reason) => ( - {reason} - ))} -
-
-
- +
+
+
+
+ {/* State column */} +
+
+ ); +} + +function StatTile({ + label, + value, + tone, + divider, +}: { + label: string; + value: string; + tone: 'value' | 'warn'; + divider?: boolean; +}) { + return ( +
+ + {label} + + + {value} + +
); } diff --git a/frontend/src/components/dashboard/HistoricalCharts.tsx b/frontend/src/components/dashboard/HistoricalCharts.tsx index 4654b950..07192d52 100644 --- a/frontend/src/components/dashboard/HistoricalCharts.tsx +++ b/frontend/src/components/dashboard/HistoricalCharts.tsx @@ -1,8 +1,7 @@ import { useMemo } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Area, AreaChart, CartesianGrid, ReferenceDot, XAxis, YAxis } from 'recharts'; import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; -import { Activity } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; import type { MetricPoint, SystemStats } from './types'; @@ -43,25 +42,57 @@ export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps const hasData = chartData.length > 0; + const cpuPeak = useMemo(() => { + if (chartData.length === 0) return null; + let peak = chartData[0]; + for (const row of chartData) { + if (row.cpu > peak.cpu) peak = row; + } + return peak; + }, [chartData]); + return (
- - - CPU Usage - - Normalized total CPU percentage over host cores (24h). +
+

CPU

+ + last 24h · normalized over cores + +
{hasData ? ( + + + + + + `${Number(val).toFixed(0)}%`} domain={[0, (dataMax: number) => Math.max(100, Math.ceil(dataMax / 10) * 10)]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> } /> - + + {cpuPeak ? ( + + ) : null} ) : ( @@ -75,21 +106,34 @@ export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps - - - RAM Usage - - Total RAM allocation in GB (24h). +
+

Memory

+ + last 24h · total allocation + +
{hasData ? ( + + + + + + `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> } /> - + ) : ( diff --git a/frontend/src/components/dashboard/ResourceGauges.tsx b/frontend/src/components/dashboard/ResourceGauges.tsx index 64426ec6..ce54ea2b 100644 --- a/frontend/src/components/dashboard/ResourceGauges.tsx +++ b/frontend/src/components/dashboard/ResourceGauges.tsx @@ -1,29 +1,27 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Cpu, MemoryStick, HardDrive, Container, Network } from 'lucide-react'; -import { - CursorProvider, - Cursor, - CursorContainer, - CursorFollow, -} from '@/components/animate-ui/primitives/animate/cursor'; -import type { Stats, SystemStats } from './types'; +import { useMemo } from 'react'; +import { Sparkline } from '@/components/ui/sparkline'; +import type { SystemStats } from './types'; interface ResourceGaugesProps { - stats: Stats; systemStats: SystemStats | null; + cpuHistory: number[]; + netHistory: number[]; + historyEndAt: number | null; } +const SPARK_WINDOW_MS = 10 * 60 * 1000; + const formatBytes = (bytes: number): string => { - if (bytes === 0) return '0 B'; + if (!bytes || bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; -const getValueColor = (value: number, warn = 80, crit = 90): string => { - if (value >= crit) return 'text-destructive/80'; - if (value >= warn) return 'text-warning/80'; +const getValueTone = (value: number, warn = 80, crit = 90): string => { + if (value >= crit) return 'text-destructive'; + if (value >= warn) return 'text-warning'; return 'text-stat-value'; }; @@ -34,135 +32,131 @@ const getBarColor = (value: number, warn = 80, crit = 90): string => { }; function GaugeBar({ value, warn = 80, crit = 90 }: { value: number; warn?: number; crit?: number }) { + const color = getBarColor(value, warn, crit); return ( -
+
); } -export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) { +export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEndAt }: ResourceGaugesProps) { const cpuVal = parseFloat(systemStats?.cpu.usage || '0'); const ramVal = parseFloat(systemStats?.memory.usagePercent || '0'); const diskVal = parseFloat(systemStats?.disk?.usagePercent || '0'); - return ( -
- {/* CPU */} - - - CPU - - - -
- {systemStats ? `${systemStats.cpu.usage}%` : '...'} -
-

- {systemStats ? `${systemStats.cpu.cores} cores` : '\u00A0'} -

- {systemStats && } -
-
+ const cpuPeak = cpuHistory.length > 0 ? Math.max(...cpuHistory) : 0; + const cpuPeakIndex = cpuHistory.length > 0 ? cpuHistory.indexOf(cpuPeak) : -1; + const cpuAvg = cpuHistory.length > 0 + ? cpuHistory.reduce((sum, v) => sum + v, 0) / cpuHistory.length + : 0; - {/* RAM */} - - - Memory - - - -
- {systemStats ? `${systemStats.memory.usagePercent}%` : '...'} -
-

- {systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'} -

- {systemStats && } -
-
+ const cpuPeakLabel = useMemo(() => { + if (cpuPeakIndex < 0 || cpuHistory.length === 0 || historyEndAt === null) return null; + const bucketMs = SPARK_WINDOW_MS / cpuHistory.length; + const ts = historyEndAt - (cpuHistory.length - 1 - cpuPeakIndex) * bucketMs; + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }, [cpuPeakIndex, cpuHistory.length, historyEndAt]); + + const netHasSignal = netHistory.some((v) => v > 0); + const netTotalPerSec = (systemStats?.network?.rxSec ?? 0) + (systemStats?.network?.txSec ?? 0); + + return ( +
+ {/* CPU hero */} +
+
+ CPU{systemStats ? ` · ${systemStats.cpu.cores} cores` : ''} +
+
+ {systemStats ? `${cpuVal.toFixed(1)}%` : '--'} +
+
+ {cpuHistory.length > 0 + ? `avg ${cpuAvg.toFixed(0)}% last 10m · peak ${cpuPeak.toFixed(0)}%${cpuPeakLabel ? ` @ ${cpuPeakLabel}` : ''}` + : 'collecting metrics…'} +
+
+ = 0 ? cpuPeakIndex : undefined} + /> +
+
+ + {/* Memory */} +
+
+ MEMORY +
+
+ {systemStats ? `${ramVal.toFixed(0)}%` : '--'} +
+
+ {systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'} +
+ {systemStats ? : null} +
{/* Disk */} - - - Disk - - - -
- {systemStats?.disk ? `${systemStats.disk.usagePercent}%` : '...'} -
-

- {systemStats?.disk ? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}` : '\u00A0'} -

- {systemStats?.disk && } -
-
- - {/* Containers */} - - - Containers - - - -
- - - {stats.active} - active - - -
- - -
-
- {stats.managed}managed - | - {stats.unmanaged}external -
-
-
- -
-

- {stats.exited} exited -

- - +
+
+ DISK +
+
+ {systemStats?.disk ? `${diskVal.toFixed(0)}%` : '--'} +
+
+ {systemStats?.disk ? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}` : '\u00A0'} +
+ {systemStats?.disk ? : null} +
{/* Network */} - - - Network - - - -
-
- RX - - {systemStats?.network ? `${formatBytes(systemStats.network.rxSec)}/s` : '...'} - -
-
- TX - - {systemStats?.network ? `${formatBytes(systemStats.network.txSec)}/s` : '...'} - -
-
-
-
+
+
+ NETWORK +
+
+ {systemStats?.network ? `${formatBytes(netTotalPerSec)}/s` : '--'} +
+
+ {systemStats?.network ? ( + <> + + {formatBytes(systemStats.network.rxSec)}/s + · + + {formatBytes(systemStats.network.txSec)}/s + + ) : ( + {'\u00A0'} + )} +
+
+ {systemStats?.network && netHasSignal ? ( + + ) : systemStats?.network ? ( + + ) : null} +
+
); } diff --git a/frontend/src/components/dashboard/StackHealthTable.tsx b/frontend/src/components/dashboard/StackHealthTable.tsx index 23b55494..a80fbeaa 100644 --- a/frontend/src/components/dashboard/StackHealthTable.tsx +++ b/frontend/src/components/dashboard/StackHealthTable.tsx @@ -1,165 +1,246 @@ -import { useMemo, useState } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; -import { ChevronRight, ChevronLeft, Layers } from 'lucide-react'; -import type { StackStatusEntry, MetricPoint } from './types'; +import { Sparkline } from '@/components/ui/sparkline'; +import { ChevronLeft, ChevronRight, Layers } from 'lucide-react'; +import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types'; interface StackHealthTableProps { stackStatuses: Record; metrics: MetricPoint[]; + stackCpuSeries: Record; + activeNodeName: string; onNavigateToStack: (stackFile: string) => void; } const PAGE_SIZE = 8; +const WARN = 80; +const CRIT = 90; +const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_minmax(0,120px)_52px_52px_72px_110px_16px]'; const formatMemory = (mb: number): string => { if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; return `${mb.toFixed(0)} MB`; }; -export function StackHealthTable({ stackStatuses, metrics, onNavigateToStack }: StackHealthTableProps) { - const [page, setPage] = useState(0); +function formatUptime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return '--'; + const days = Math.floor(seconds / 86400); + if (days > 0) return `${days}d`; + const hours = Math.floor(seconds / 3600); + if (hours > 0) return `${hours}h`; + const minutes = Math.floor(seconds / 60); + if (minutes > 0) return `${minutes}m`; + return `${Math.max(1, Math.floor(seconds))}s`; +} - const stackMetrics = useMemo(() => { +type RowState = 'healthy' | 'warn' | 'error'; + +function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState { + if (status === 'exited') return 'error'; + if (peakCpu >= CRIT) return 'error'; + if (peakCpu >= WARN) return 'warn'; + return 'healthy'; +} + +const stateDot: Record = { + healthy: 'bg-success', + warn: 'bg-warning', + error: 'bg-destructive', +}; + +const rowTint: Record = { + healthy: '', + warn: 'bg-warning/[0.04]', + error: 'bg-destructive/[0.04]', +}; + +const sparkStroke: Record = { + healthy: 'var(--chart-1)', + warn: 'var(--warning)', + error: 'var(--destructive)', +}; + +export function StackHealthTable({ + stackStatuses, + metrics, + stackCpuSeries, + activeNodeName, + onNavigateToStack, +}: StackHealthTableProps) { + const [page, setPage] = useState(0); + // Live-tick the current second so uptime labels advance without a parent + // refetch. Thirty-second cadence keeps the DOM calm while still refreshing + // every "Nm" bucket change. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 30000); + return () => clearInterval(id); + }, []); + + const stackAggregates = useMemo(() => { const latestPerContainer: Record> = {}; for (const m of metrics) { if (!m.stack_name) continue; - const stack = m.stack_name; - if (!latestPerContainer[stack]) latestPerContainer[stack] = {}; - const existing = latestPerContainer[stack][m.container_id]; + if (!latestPerContainer[m.stack_name]) latestPerContainer[m.stack_name] = {}; + const existing = latestPerContainer[m.stack_name][m.container_id]; if (!existing || m.timestamp > existing.timestamp) { - latestPerContainer[stack][m.container_id] = m; + latestPerContainer[m.stack_name][m.container_id] = m; } } - - const result: Record = {}; + const result: Record = {}; for (const [stack, containers] of Object.entries(latestPerContainer)) { let mem = 0; + let cpu = 0; for (const m of Object.values(containers)) { mem += m.memory_mb; + cpu += m.cpu_percent; } - result[stack] = { mem }; + result[stack] = { mem, cpu }; } return result; }, [metrics]); const rows = useMemo(() => { - return Object.entries(stackStatuses) - .map(([file, entry]) => { - const name = file.replace(/\.(yml|yaml)$/, ''); - const m = stackMetrics[name]; - return { - file, - name, - status: entry.status, - memory: m?.mem ?? null, - }; - }) - .sort((a, b) => { - const statusOrder = { running: 0, exited: 1, unknown: 2 }; - const diff = statusOrder[a.status] - statusOrder[b.status]; - if (diff !== 0) return diff; - return a.name.localeCompare(b.name); - }); - }, [stackStatuses, stackMetrics]); + const list = Object.entries(stackStatuses).map(([file, entry]) => { + const name = file.replace(/\.(yml|yaml)$/, ''); + const agg = stackAggregates[name]; + const series = stackCpuSeries[name]; + const peakCpu = series?.peakValue ?? agg?.cpu ?? 0; + const state = classifyRow(entry.status, peakCpu); + return { + file, + name, + status: entry.status, + memory: agg?.mem ?? null, + cpu: agg?.cpu ?? null, + peakCpu, + series: series?.points ?? [], + peakIndex: series?.peakIndex ?? -1, + state, + runningSince: entry.runningSince ?? null, + }; + }); + const stateOrder: Record = { error: 0, warn: 1, healthy: 2 }; + list.sort((a, b) => { + const diff = stateOrder[a.state] - stateOrder[b.state]; + if (diff !== 0) return diff; + return b.peakCpu - a.peakCpu; + }); + return list; + }, [stackStatuses, stackAggregates, stackCpuSeries]); const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); - // Clamp page to valid range (handles node switch reducing the stack count) const safePage = Math.min(page, totalPages - 1); const pagedRows = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); const needsPagination = rows.length > PAGE_SIZE; - const statusDisplay: Record = { - running: { label: 'UP', className: 'text-success' }, - exited: { label: 'DN', className: 'text-destructive' }, - unknown: { label: '--', className: 'text-stat-icon' }, - }; + const stackCount = Object.keys(stackStatuses).length; - if (Object.keys(stackStatuses).length === 0) { + if (stackCount === 0) { return ( - - -
- -

No stacks found. Create one from the sidebar.

-
-
-
+
+
+ +

No stacks found. Create one from the sidebar.

+
+
); } return ( - - -
- Stack Health - {needsPagination && ( -
- - - {safePage + 1} / {totalPages} - - -
- )} +
+
+
+

+ Stack health +

+ + {stackCount} {stackCount === 1 ? 'stack' : 'stacks'} · sorted by load +
- - - - - - Stack - Status - Memory - - - - - {pagedRows.map(row => { - const sd = statusDisplay[row.status] || statusDisplay.unknown; - return ( - onNavigateToStack(row.file)} - > - - {row.name} - - - {sd.label} - - - - {row.memory !== null ? formatMemory(row.memory) : '--'} - - - - - - - ); - })} - -
-
- + {needsPagination ? ( +
+ + + {safePage + 1} / {totalPages} + + +
+ ) : null} +
+
+ + STACK + HOST + UP + CPU + MEM + CPU · 10m + +
+
    + {pagedRows.map((row) => ( +
  • onNavigateToStack(row.file)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onNavigateToStack(row.file); + } + }} + className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-5 py-3 transition-colors hover:bg-accent/5 ${rowTint[row.state]}`} + > +
  • + ))} +
+
); } diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index 4477e355..273ae35d 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -56,13 +56,30 @@ export interface NotificationItem { export interface StackStatusEntry { status: 'running' | 'exited' | 'unknown'; mainPort?: number; + /** Unix seconds of the oldest running container (approximates stack uptime). */ + runningSince?: number; } export type HealthLevel = 'healthy' | 'degraded' | 'critical'; +export interface StackCpuSeries { + stackName: string; + points: number[]; + peakValue: number; + peakIndex: number; + latestValue: number; +} + export interface DashboardData { stats: Stats; systemStats: SystemStats | null; metrics: MetricPoint[]; stackStatuses: Record; + lastSyncAt: number | null; + nodeCount: number; + stackCpuSeries: Record; + cpuHistory: number[]; + netHistory: number[]; + /** Anchor timestamp (ms) for the sparkline 10-minute window — the newest metric sample. */ + historyEndAt: number | null; } diff --git a/frontend/src/components/dashboard/useDashboardData.ts b/frontend/src/components/dashboard/useDashboardData.ts index 8bc5bf00..a76e6c90 100644 --- a/frontend/src/components/dashboard/useDashboardData.ts +++ b/frontend/src/components/dashboard/useDashboardData.ts @@ -1,9 +1,18 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNodes } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; -import type { Stats, SystemStats, MetricPoint, StackStatusEntry, DashboardData } from './types'; +import type { + Stats, + SystemStats, + MetricPoint, + StackStatusEntry, + DashboardData, + StackCpuSeries, +} from './types'; const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 }; +const SPARK_BUCKETS = 20; +const SPARK_WINDOW_MS = 10 * 60 * 1000; /** * Start a polling interval that pauses when the tab is hidden. @@ -42,14 +51,41 @@ function visibilityInterval(fn: () => void, ms: number): () => void { }; } +function bucketCpu(points: MetricPoint[], windowMs: number, buckets: number): number[] { + if (points.length === 0) return Array(buckets).fill(0); + const now = Date.now(); + const start = now - windowMs; + const bucketMs = windowMs / buckets; + const out = Array(buckets).fill(0); + const counts = Array(buckets).fill(0); + for (const p of points) { + if (p.timestamp < start) continue; + const idx = Math.min(buckets - 1, Math.max(0, Math.floor((p.timestamp - start) / bucketMs))); + out[idx] += p.cpu_percent; + counts[idx] += 1; + } + for (let i = 0; i < buckets; i += 1) { + if (counts[i] > 0) out[i] = out[i] / counts[i]; + } + // Forward-fill empty buckets from the previous non-empty one so the line + // reads as a continuous trend rather than a sawtooth of zeros. + let last = 0; + for (let i = 0; i < buckets; i += 1) { + if (counts[i] === 0) out[i] = last; + else last = out[i]; + } + return out; +} + export function useDashboardData(): DashboardData { - const { activeNode } = useNodes(); + const { activeNode, nodes } = useNodes(); const nodeId = activeNode?.id; const [stats, setStats] = useState(DEFAULT_STATS); const [systemStats, setSystemStats] = useState(null); const [metrics, setMetrics] = useState([]); const [stackStatuses, setStackStatuses] = useState>({}); + const [lastSyncAt, setLastSyncAt] = useState(null); // Keep a ref to the latest nodeId so async callbacks don't write stale data // after a node switch has already triggered a new effect cycle. @@ -69,12 +105,14 @@ export function useDashboardData(): DashboardData { // Container stats: 5s polling, resets on node change useEffect(() => { setStats(DEFAULT_STATS); // eslint-disable-line react-hooks/set-state-in-effect + setLastSyncAt(null); const currentNodeId = nodeId; const fetchStats = async () => { if (nodeIdRef.current !== currentNodeId) return; // Stale effect const data = await fetchJson('/stats'); if (data && nodeIdRef.current === currentNodeId) { setStats(data); + setLastSyncAt(Date.now()); } }; fetchStats(); @@ -126,5 +164,123 @@ export function useDashboardData(): DashboardData { return cleanup; }, [nodeId, fetchJson]); - return { stats, systemStats, metrics, stackStatuses }; + const stackCpuSeries = useMemo>(() => { + if (metrics.length === 0) return {}; + const grouped = new Map(); + for (const point of metrics) { + if (!point.stack_name) continue; + const bucket = grouped.get(point.stack_name) ?? []; + bucket.push(point); + grouped.set(point.stack_name, bucket); + } + const out: Record = {}; + for (const [stackName, rows] of grouped) { + const points = bucketCpu(rows, SPARK_WINDOW_MS, SPARK_BUCKETS); + let peakValue = -Infinity; + let peakIndex = 0; + for (let i = 0; i < points.length; i += 1) { + if (points[i] > peakValue) { + peakValue = points[i]; + peakIndex = i; + } + } + out[stackName] = { + stackName, + points, + peakValue: peakValue === -Infinity ? 0 : peakValue, + peakIndex, + latestValue: points[points.length - 1] ?? 0, + }; + } + return out; + }, [metrics]); + + const cores = systemStats?.cpu.cores || 1; + + // Anchor the 10-minute sparkline window to the newest metric sample so the + // bucketing memos stay pure (calling Date.now() inside useMemo would violate + // react-hooks/purity and could yield inconsistent bucket boundaries across + // re-renders). + const historyEndAt = useMemo(() => { + if (metrics.length === 0) return null; + let max = metrics[0].timestamp; + for (let i = 1; i < metrics.length; i += 1) { + if (metrics[i].timestamp > max) max = metrics[i].timestamp; + } + return max; + }, [metrics]); + + // Aggregate host-level CPU normalized over cores, so the sparkline matches + // the gauge percentage rather than summing raw container usage. + const cpuHistory = useMemo(() => { + if (metrics.length === 0 || historyEndAt === null) return Array(SPARK_BUCKETS).fill(0); + const start = historyEndAt - SPARK_WINDOW_MS; + const bucketMs = SPARK_WINDOW_MS / SPARK_BUCKETS; + // Per-bucket sum across all containers, tracking how many distinct + // timestamps contributed so we can average per bucket. + const bucketSum = Array(SPARK_BUCKETS).fill(0); + const bucketTimestamps = Array.from({ length: SPARK_BUCKETS }, () => new Set()); + for (const p of metrics) { + if (p.timestamp < start) continue; + const idx = Math.min(SPARK_BUCKETS - 1, Math.max(0, Math.floor((p.timestamp - start) / bucketMs))); + bucketSum[idx] += p.cpu_percent / cores; + bucketTimestamps[idx].add(p.timestamp); + } + const out = Array(SPARK_BUCKETS).fill(0); + let last = 0; + for (let i = 0; i < SPARK_BUCKETS; i += 1) { + const tsCount = bucketTimestamps[i].size; + if (tsCount > 0) { + out[i] = bucketSum[i] / tsCount; + last = out[i]; + } else { + out[i] = last; + } + } + return out; + }, [metrics, cores, historyEndAt]); + + // Network throughput over time: compute per-container deltas between + // consecutive samples, assign each delta to the bucket of the later sample, + // and sum across containers. This is robust to container churn because each + // delta is paired within a single container's lifeline. Negative deltas + // (counter reset after a restart) clamp to zero. + const netHistory = useMemo(() => { + if (metrics.length === 0 || historyEndAt === null) return Array(SPARK_BUCKETS).fill(0); + const start = historyEndAt - SPARK_WINDOW_MS; + const bucketMs = SPARK_WINDOW_MS / SPARK_BUCKETS; + const byContainer = new Map(); + for (const p of metrics) { + const bucket = byContainer.get(p.container_id) ?? []; + bucket.push(p); + byContainer.set(p.container_id, bucket); + } + const out = Array(SPARK_BUCKETS).fill(0); + for (const samples of byContainer.values()) { + samples.sort((a, b) => a.timestamp - b.timestamp); + for (let i = 1; i < samples.length; i += 1) { + const curr = samples[i]; + if (curr.timestamp < start) continue; + const prev = samples[i - 1]; + const delta = (curr.net_rx_mb + curr.net_tx_mb) - (prev.net_rx_mb + prev.net_tx_mb); + if (delta <= 0) continue; + const idx = Math.min(SPARK_BUCKETS - 1, Math.max(0, Math.floor((curr.timestamp - start) / bucketMs))); + out[idx] += delta; + } + } + return out; + }, [metrics, historyEndAt]); + + return { + stats, + systemStats, + metrics, + stackStatuses, + lastSyncAt, + nodeCount: nodes.length, + stackCpuSeries, + cpuHistory, + netHistory, + historyEndAt, + }; } diff --git a/frontend/src/components/ui/sparkline.tsx b/frontend/src/components/ui/sparkline.tsx new file mode 100644 index 00000000..55c2c482 --- /dev/null +++ b/frontend/src/components/ui/sparkline.tsx @@ -0,0 +1,124 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +type SvgSvgAttrs = Omit, "points" | "strokeWidth"> + +export interface SparklineProps extends SvgSvgAttrs { + points: number[] + stroke?: string + fill?: string + peakIndex?: number + peakColor?: string + showPeak?: boolean + strokeWidth?: number + min?: number + max?: number + width?: number + height?: number +} + +const VIEW_W = 100 +const VIEW_H = 28 +const PEAK_R = 0.9 + +export const Sparkline = React.forwardRef( + ( + { + points, + stroke = "var(--chart-1)", + fill = "var(--chart-1)", + peakIndex, + peakColor = "var(--data-peak)", + showPeak = true, + strokeWidth, + min, + max, + width, + height, + className, + ...rest + }, + ref, + ) => { + const id = React.useId() + + if (!points || points.length < 2) { + return ( + + ) + } + + const lo = min ?? Math.min(...points) + const hi = max ?? Math.max(...points) + const range = hi - lo || 1 + + const coords = points.map((v, i) => { + const x = (i / (points.length - 1)) * VIEW_W + const y = VIEW_H - ((v - lo) / range) * VIEW_H + return [x, y] as const + }) + + const line = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`).join(" ") + const area = `${line} L${VIEW_W.toFixed(2)},${VIEW_H} L0,${VIEW_H} Z` + + const resolvedPeakIndex = + typeof peakIndex === "number" + ? peakIndex + : points.indexOf(Math.max(...points)) + + const peak = showPeak && resolvedPeakIndex >= 0 ? coords[resolvedPeakIndex] : null + + return ( + + ) + }, +) + +Sparkline.displayName = "Sparkline"