feat(dashboard): status masthead, unified gauges, stack health sparklines (#676)

* feat(dashboard): status masthead, unified gauges, stack health sparklines

Rework the home dashboard around a single cyan-railed status masthead
that carries the health state word, node meta, and reasons inline. The
resource block collapses into one strip with a CPU hero sparkline,
memory and disk gauge bars, and a network tile whose sparkline is built
from per-container byte-counter deltas. Stack health becomes an
8-column mono table with row tinting, an uptime column sourced from
the oldest running container's creation time, and a per-stack 10-minute
CPU sparkline. Historical charts pick up a cyan gradient and an amber
peak marker. A shared Sparkline primitive backs all of the above.

* docs(dashboard): refresh screenshot for phase B layout

* fix(dashboard): anchor sparkline bucketing to latest metric timestamp

The cpuHistory, netHistory, and cpuPeakLabel memos called Date.now()
inside useMemo, which violates react-hooks/purity: the rule fires
because re-renders can produce different bucket boundaries from the
same inputs. Derive a historyEndAt anchor from the newest metric
sample in the polled series and thread it through to ResourceGauges.
This commit is contained in:
Anso
2026-04-18 01:36:12 -04:00
committed by GitHub
parent 7ec189dc35
commit 748ba46669
12 changed files with 914 additions and 359 deletions
+1 -1
View File
@@ -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<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number }> = {};
const data: Record<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number; runningSince?: number }> = {};
for (const stack of stacks) {
const name = stack.replace(/\.(yml|yaml)$/, '');
data[stack] = bulkInfo[name] ?? { status: 'unknown' };
+14
View File
@@ -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 }[];
+33 -23
View File
@@ -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.
<Frame>
<img src="/images/dashboard/dashboard-overview.png" alt="Sencho dashboard showing health status, resource gauges, stack health table, and historical charts" />
<img src="/images/dashboard/dashboard-overview.png" alt="Sencho dashboard showing status masthead, unified gauge strip, stack health table, and historical charts" />
</Frame>
## 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 122 KiB

+12 -3
View File
@@ -15,9 +15,12 @@ interface HomeDashboardProps {
onClearNotifications: () => void | Promise<void>;
}
const NOOP = () => {};
export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) {
const { activeNode, nodes } = useNodes();
const data = useDashboardData();
const activeNodeName = activeNode?.name || 'Local';
return (
<div className="flex-1 p-6 space-y-4">
@@ -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}
/>
<ResourceGauges
stats={data.stats}
systemStats={data.systemStats}
cpuHistory={data.cpuHistory}
netHistory={data.netHistory}
historyEndAt={data.historyEndAt}
/>
<StackHealthTable
stackStatuses={data.stackStatuses}
metrics={data.metrics}
onNavigateToStack={onNavigateToStack || (() => {})}
stackCpuSeries={data.stackCpuSeries}
activeNodeName={activeNodeName}
onNavigateToStack={onNavigateToStack ?? NOOP}
/>
<HistoricalCharts
@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { Card } from '@/components/ui/card';
import { Activity, Bell } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Bell } from 'lucide-react';
import {
CursorProvider,
Cursor,
@@ -14,6 +13,8 @@ interface HealthStatusBarProps {
systemStats: SystemStats | null;
notifications: NotificationItem[];
activeNodeName: string;
nodeCount: number;
lastSyncAt: number | null;
}
interface HealthResult {
@@ -28,18 +29,11 @@ function deriveHealth(stats: Stats, systemStats: SystemStats | null, notificatio
const unreadErrors = notifications.filter(n => !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<HealthLevel, { label: string; dotClass: string; textClass: string }> = {
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<HealthLevel, { label: string; dotClass: string; textClass: string; railClass: string; tintClass: string }> = {
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 (
<Card className="bg-card shadow-card-bevel px-4 py-3">
<div className="flex items-center justify-between gap-4 flex-wrap">
{/* Health badge */}
<div className="flex items-center gap-3">
<div className="relative">
<CursorProvider>
<CursorContainer className="flex items-center gap-2">
<div className={`h-2.5 w-2.5 rounded-full ${config.dotClass}`} />
<span className={`font-mono text-sm font-medium ${config.textClass}`}>
{config.label}
</span>
</CursorContainer>
<Cursor>
<div className={`h-2 w-2 rounded-full ${level === 'healthy' ? 'bg-brand' : level === 'degraded' ? 'bg-warning' : 'bg-destructive'}`} />
</Cursor>
<CursorFollow
side="bottom"
sideOffset={4}
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-2.5 py-1.5 shadow-md">
<div className="flex flex-col gap-0.5 font-mono text-xs tabular-nums">
{reasons.map((reason) => (
<span key={reason} className="text-stat-value whitespace-nowrap">{reason}</span>
))}
</div>
</div>
</CursorFollow>
</CursorProvider>
<div
className={`relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors`}
>
<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">
{/* State column */}
<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 className="h-4 w-px bg-border" />
<span className="font-mono text-sm text-stat-subtitle">{activeNodeName}</span>
</div>
{/* Right side: counts + last updated */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-sm text-stat-subtitle">
<Activity className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
<span className="font-mono tabular-nums">{stats.active}</span>
<span>running</span>
</div>
{unreadAlerts > 0 && (
<div className="flex items-center gap-1.5 text-sm text-warning">
<Bell className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono tabular-nums">{unreadAlerts}</span>
<span>alert{unreadAlerts !== 1 ? 's' : ''}</span>
</div>
)}
{/* Stats column */}
<div className="hidden items-stretch justify-end gap-0 md:flex">
<CursorProvider>
<CursorContainer>
<StatTile label="RUNNING" value={running} tone="value" />
</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">
{stats.managed}
<span className="ml-1 font-sans text-stat-subtitle">managed</span>
</span>
<span className="text-stat-icon">·</span>
<span className="text-stat-value">
{stats.unmanaged}
<span className="ml-1 font-sans text-stat-subtitle">external</span>
</span>
{stats.exited > 0 ? (
<>
<span className="text-stat-icon">·</span>
<span className="text-destructive">
{stats.exited}
<span className="ml-1 font-sans text-stat-subtitle">exited</span>
</span>
</>
) : null}
</div>
</div>
</CursorFollow>
</CursorProvider>
<StatTile label="CPU" value={cpuLabel} tone={parseFloat(systemStats?.cpu.usage || '0') >= 80 ? 'warn' : 'value'} divider />
<StatTile label="MEM" value={memLabel} tone="value" divider />
</div>
{/* Right column */}
<div className="flex items-center gap-2 pl-4">
<Bell
className={`h-3.5 w-3.5 ${unreadAlerts > 0 ? 'text-warning' : 'text-stat-icon'}`}
strokeWidth={1.5}
/>
<span
className={`font-mono text-sm tabular-nums ${unreadAlerts > 0 ? 'text-warning' : 'text-stat-subtitle'}`}
>
{unreadAlerts}
</span>
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-stat-subtitle">
{unreadAlerts === 1 ? 'alert' : 'alerts'}
</span>
</div>
</div>
</Card>
</div>
);
}
function StatTile({
label,
value,
tone,
divider,
}: {
label: string;
value: 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>
</div>
);
}
@@ -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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-card shadow-card-bevel">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" strokeWidth={1.5} />
<span>CPU Usage</span>
</CardTitle>
<CardDescription className="text-xs">Normalized total CPU percentage over host cores (24h).</CardDescription>
<div className="flex items-baseline gap-3">
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">CPU</h2>
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
last 24h · normalized over cores
</span>
</div>
</CardHeader>
<CardContent className="h-[250px]">
{hasData ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="dash-cpu-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity={0.35} />
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, (dataMax: number) => Math.max(100, Math.ceil(dataMax / 10) * 10)]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="cpu" stroke="var(--color-cpu)" fill="var(--color-cpu)" fillOpacity={0.4} />
<Area
type="monotone"
dataKey="cpu"
stroke="var(--chart-1)"
strokeWidth={1.25}
fill="url(#dash-cpu-fill)"
/>
{cpuPeak ? (
<ReferenceDot
x={cpuPeak.time}
y={cpuPeak.cpu}
r={3}
fill="var(--chart-2)"
stroke="var(--background)"
strokeWidth={1}
/>
) : null}
</AreaChart>
</ChartContainer>
) : (
@@ -75,21 +106,34 @@ export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps
<Card className="bg-card shadow-card-bevel">
<CardHeader className="pb-2">
<CardTitle className="flex items-center space-x-2 text-sm font-medium text-stat-title">
<Activity className="w-4 h-4 text-stat-icon" strokeWidth={1.5} />
<span>RAM Usage</span>
</CardTitle>
<CardDescription className="text-xs">Total RAM allocation in GB (24h).</CardDescription>
<div className="flex items-baseline gap-3">
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">Memory</h2>
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
last 24h · total allocation
</span>
</div>
</CardHeader>
<CardContent className="h-[250px]">
{hasData ? (
<ChartContainer config={chartConfig} className="w-full h-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="dash-ram-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--chart-2)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--chart-2)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area type="monotone" dataKey="ram" stroke="var(--color-ram)" fill="var(--color-ram)" fillOpacity={0.4} />
<Area
type="monotone"
dataKey="ram"
stroke="var(--chart-2)"
strokeWidth={1.25}
fill="url(#dash-ram-fill)"
/>
</AreaChart>
</ChartContainer>
) : (
@@ -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 (
<div className="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div className="mt-3 h-1 rounded-full bg-muted/60 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${Math.min(value, 100)}%`, backgroundColor: getBarColor(value, warn, crit) }}
style={{
width: `${Math.min(value, 100)}%`,
backgroundColor: color,
boxShadow: `0 0 8px ${color}, 0 0 2px ${color}`,
}}
/>
</div>
);
}
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 (
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{/* CPU */}
<Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">CPU</CardTitle>
<Cpu className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className={`text-2xl font-medium font-mono tabular-nums tracking-tight ${systemStats ? getValueColor(cpuVal) : 'text-stat-value'}`}>
{systemStats ? `${systemStats.cpu.usage}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-0.5">
{systemStats ? `${systemStats.cpu.cores} cores` : '\u00A0'}
</p>
{systemStats && <GaugeBar value={cpuVal} />}
</CardContent>
</Card>
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 */}
<Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Memory</CardTitle>
<MemoryStick className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className={`text-2xl font-medium font-mono tabular-nums tracking-tight ${systemStats ? getValueColor(ramVal) : 'text-stat-value'}`}>
{systemStats ? `${systemStats.memory.usagePercent}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-0.5">
{systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'}
</p>
{systemStats && <GaugeBar value={ramVal} />}
</CardContent>
</Card>
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 (
<div className="grid grid-cols-1 overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel md:grid-cols-[2fr_1fr_1fr_1fr]">
{/* CPU hero */}
<div className="relative p-5 md:border-r md:border-border/60">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
CPU{systemStats ? ` · ${systemStats.cpu.cores} cores` : ''}
</div>
<div className={`mt-2 font-mono tabular-nums text-4xl leading-none ${systemStats ? getValueTone(cpuVal) : 'text-stat-value'}`}>
{systemStats ? `${cpuVal.toFixed(1)}%` : '--'}
</div>
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
{cpuHistory.length > 0
? `avg ${cpuAvg.toFixed(0)}% last 10m · peak ${cpuPeak.toFixed(0)}%${cpuPeakLabel ? ` @ ${cpuPeakLabel}` : ''}`
: 'collecting metrics…'}
</div>
<div className="mt-3 h-14 w-full">
<Sparkline
points={cpuHistory}
stroke="var(--chart-1)"
fill="var(--chart-1)"
peakColor="var(--chart-2)"
peakIndex={cpuPeakIndex >= 0 ? cpuPeakIndex : undefined}
/>
</div>
</div>
{/* Memory */}
<div className="p-5 border-t border-border/60 md:border-t-0 md:border-r">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
MEMORY
</div>
<div className={`mt-2 font-mono tabular-nums text-2xl leading-none ${systemStats ? getValueTone(ramVal) : 'text-stat-value'}`}>
{systemStats ? `${ramVal.toFixed(0)}%` : '--'}
</div>
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
{systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'}
</div>
{systemStats ? <GaugeBar value={ramVal} /> : null}
</div>
{/* Disk */}
<Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Disk</CardTitle>
<HardDrive className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className={`text-2xl font-medium font-mono tabular-nums tracking-tight ${systemStats?.disk ? getValueColor(diskVal) : 'text-stat-value'}`}>
{systemStats?.disk ? `${systemStats.disk.usagePercent}%` : '...'}
</div>
<p className="text-xs text-stat-subtitle mt-0.5">
{systemStats?.disk ? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}` : '\u00A0'}
</p>
{systemStats?.disk && <GaugeBar value={diskVal} />}
</CardContent>
</Card>
{/* Containers */}
<Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Containers</CardTitle>
<Container className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className="relative">
<CursorProvider>
<CursorContainer className="inline-flex items-baseline">
<span className="text-2xl font-medium font-mono tabular-nums tracking-tight text-stat-value">{stats.active}</span>
<span className="text-sm text-stat-subtitle ml-1.5">active</span>
</CursorContainer>
<Cursor>
<div className="h-2 w-2 rounded-full bg-brand" />
</Cursor>
<CursorFollow
side="bottom"
sideOffset={4}
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-2.5 py-1.5 shadow-md">
<div className="flex items-center gap-3 font-mono text-xs tabular-nums">
<span className="text-stat-value">{stats.managed}<span className="text-stat-subtitle ml-1 font-sans">managed</span></span>
<span className="text-stat-icon">|</span>
<span className="text-stat-value">{stats.unmanaged}<span className="text-stat-subtitle ml-1 font-sans">external</span></span>
</div>
</div>
</CursorFollow>
</CursorProvider>
</div>
<p className="text-xs text-stat-subtitle mt-0.5">
<span className="font-mono tabular-nums text-destructive/80">{stats.exited}</span> exited
</p>
</CardContent>
</Card>
<div className="p-5 border-t border-border/60 md:border-t-0 md:border-r">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
DISK
</div>
<div className={`mt-2 font-mono tabular-nums text-2xl leading-none ${systemStats?.disk ? getValueTone(diskVal) : 'text-stat-value'}`}>
{systemStats?.disk ? `${diskVal.toFixed(0)}%` : '--'}
</div>
<div className="mt-1.5 font-mono text-[11px] text-stat-subtitle">
{systemStats?.disk ? `${formatBytes(systemStats.disk.used)} / ${formatBytes(systemStats.disk.total)}` : '\u00A0'}
</div>
{systemStats?.disk ? <GaugeBar value={diskVal} /> : null}
</div>
{/* Network */}
<Card className="bg-card shadow-card-bevel">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1">
<CardTitle className="text-xs font-medium text-stat-title">Network</CardTitle>
<Network className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-1 mt-1">
<div className="flex items-center gap-2">
<span className="text-xs text-stat-icon">RX</span>
<span className="text-sm font-mono tabular-nums text-stat-value">
{systemStats?.network ? `${formatBytes(systemStats.network.rxSec)}/s` : '...'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-stat-icon">TX</span>
<span className="text-sm font-mono tabular-nums text-stat-value">
{systemStats?.network ? `${formatBytes(systemStats.network.txSec)}/s` : '...'}
</span>
</div>
</div>
</CardContent>
</Card>
<div className="p-5 border-t border-border/60 md:border-t-0">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
NETWORK
</div>
<div className="mt-2 font-mono tabular-nums text-2xl leading-none text-stat-value">
{systemStats?.network ? `${formatBytes(netTotalPerSec)}/s` : '--'}
</div>
<div className="mt-1.5 flex items-center gap-2 font-mono text-[11px] text-stat-subtitle">
{systemStats?.network ? (
<>
<span className="text-stat-icon"></span>
<span className="tabular-nums text-stat-value">{formatBytes(systemStats.network.rxSec)}/s</span>
<span className="text-stat-icon">·</span>
<span className="text-stat-icon"></span>
<span className="tabular-nums text-stat-value">{formatBytes(systemStats.network.txSec)}/s</span>
</>
) : (
<span>{'\u00A0'}</span>
)}
</div>
<div className="mt-3 h-5 w-full">
{systemStats?.network && netHasSignal ? (
<Sparkline
points={netHistory}
stroke="var(--chart-1)"
fill="var(--chart-1)"
showPeak={false}
strokeWidth={1}
/>
) : systemStats?.network ? (
<span className="block h-full w-full border-b border-dashed border-border/60" />
) : null}
</div>
</div>
</div>
);
}
@@ -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<string, StackStatusEntry>;
metrics: MetricPoint[];
stackCpuSeries: Record<string, StackCpuSeries>;
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<RowState, string> = {
healthy: 'bg-success',
warn: 'bg-warning',
error: 'bg-destructive',
};
const rowTint: Record<RowState, string> = {
healthy: '',
warn: 'bg-warning/[0.04]',
error: 'bg-destructive/[0.04]',
};
const sparkStroke: Record<RowState, string> = {
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<string, Record<string, MetricPoint>> = {};
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<string, { mem: number }> = {};
const result: Record<string, { mem: number; cpu: number }> = {};
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<RowState, number> = { 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<string, { label: string; className: string }> = {
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 (
<Card className="bg-card shadow-card-bevel">
<CardContent className="py-8">
<div className="flex flex-col items-center justify-center gap-2 text-stat-subtitle">
<Layers className="h-8 w-8 text-stat-icon" strokeWidth={1.5} />
<p className="text-sm">No stacks found. Create one from the sidebar.</p>
</div>
</CardContent>
</Card>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel py-10">
<div className="flex flex-col items-center justify-center gap-2 text-stat-subtitle">
<Layers className="h-8 w-8 text-stat-icon" strokeWidth={1.5} />
<p className="text-sm">No stacks found. Create one from the sidebar.</p>
</div>
</div>
);
}
return (
<Card className="bg-card shadow-card-bevel">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium text-stat-title">Stack Health</CardTitle>
{needsPagination && (
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={safePage === 0}
onClick={() => setPage(safePage - 1)}
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(safePage + 1)}
>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-baseline gap-3">
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">
Stack health
</h2>
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
{stackCount} {stackCount === 1 ? 'stack' : 'stacks'} · sorted by load
</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-border">
<TableHead className="text-xs text-stat-icon font-medium h-8">Stack</TableHead>
<TableHead className="text-xs text-stat-icon font-medium h-8 w-[60px]">Status</TableHead>
<TableHead className="text-xs text-stat-icon font-medium h-8 w-[90px] text-right">Memory</TableHead>
<TableHead className="h-8 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{pagedRows.map(row => {
const sd = statusDisplay[row.status] || statusDisplay.unknown;
return (
<TableRow
key={row.file}
className="cursor-pointer border-b border-border/50 hover:bg-accent/5"
onClick={() => onNavigateToStack(row.file)}
>
<TableCell className="py-2.5">
<span className="font-mono text-sm text-stat-value">{row.name}</span>
</TableCell>
<TableCell className="py-2.5">
<span className={`font-mono text-xs font-medium ${sd.className}`}>{sd.label}</span>
</TableCell>
<TableCell className="py-2.5 text-right">
<span className="font-mono text-xs tabular-nums text-stat-subtitle">
{row.memory !== null ? formatMemory(row.memory) : '--'}
</span>
</TableCell>
<TableCell className="py-2.5">
<ChevronRight className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{needsPagination ? (
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={safePage === 0}
onClick={() => setPage(safePage - 1)}
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={safePage >= totalPages - 1}
onClick={() => setPage(safePage + 1)}
>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</div>
) : null}
</div>
<div className={`grid ${GRID_TEMPLATE} items-center gap-4 border-t border-border/60 px-5 py-2 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle`}>
<span />
<span>STACK</span>
<span>HOST</span>
<span className="text-right">UP</span>
<span className="text-right">CPU</span>
<span className="text-right">MEM</span>
<span className="text-right">CPU · 10m</span>
<span />
</div>
<ul className="divide-y divide-border/40">
{pagedRows.map((row) => (
<li
key={row.file}
role="button"
tabIndex={0}
onClick={() => 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]}`}
>
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
<span className="truncate font-mono text-[13px] text-stat-value">{row.name}</span>
<span className="truncate font-mono text-xs text-stat-subtitle">{activeNodeName}</span>
<span className="text-right font-mono text-xs tabular-nums text-stat-subtitle">
{row.runningSince !== null
? formatUptime(Math.max(0, Math.floor(now / 1000 - row.runningSince)))
: '--'}
</span>
<span className="text-right font-mono text-xs tabular-nums text-stat-subtitle">
{row.cpu !== null ? `${row.cpu.toFixed(0)}%` : '--'}
</span>
<span className="text-right font-mono text-xs tabular-nums text-stat-subtitle">
{row.memory !== null ? formatMemory(row.memory) : '--'}
</span>
<span className="ml-auto block h-5 w-[110px]">
{row.series.length > 1 ? (
<Sparkline
points={row.series}
stroke={sparkStroke[row.state]}
fill={sparkStroke[row.state]}
peakColor="var(--chart-2)"
peakIndex={row.peakIndex >= 0 ? row.peakIndex : undefined}
showPeak={row.state !== 'healthy'}
/>
) : (
<span className="block h-full w-full border-b border-dashed border-border/60" />
)}
</span>
<ChevronRight className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</li>
))}
</ul>
</div>
);
}
@@ -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<string, StackStatusEntry>;
lastSyncAt: number | null;
nodeCount: number;
stackCpuSeries: Record<string, StackCpuSeries>;
cpuHistory: number[];
netHistory: number[];
/** Anchor timestamp (ms) for the sparkline 10-minute window — the newest metric sample. */
historyEndAt: number | null;
}
@@ -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<number>(buckets).fill(0);
const counts = Array<number>(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<Stats>(DEFAULT_STATS);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
const [stackStatuses, setStackStatuses] = useState<Record<string, StackStatusEntry>>({});
const [lastSyncAt, setLastSyncAt] = useState<number | null>(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>('/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<Record<string, StackCpuSeries>>(() => {
if (metrics.length === 0) return {};
const grouped = new Map<string, MetricPoint[]>();
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<string, StackCpuSeries> = {};
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<number | null>(() => {
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<number[]>(() => {
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<number>(SPARK_BUCKETS).fill(0);
const bucketTimestamps = Array.from({ length: SPARK_BUCKETS }, () => new Set<number>());
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<number>(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<number[]>(() => {
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<string, MetricPoint[]>();
for (const p of metrics) {
const bucket = byContainer.get(p.container_id) ?? [];
bucket.push(p);
byContainer.set(p.container_id, bucket);
}
const out = Array<number>(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,
};
}
+124
View File
@@ -0,0 +1,124 @@
import * as React from "react"
import { cn } from "@/lib/utils"
type SvgSvgAttrs = Omit<React.SVGAttributes<SVGSVGElement>, "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<SVGSVGElement, SparklineProps>(
(
{
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 (
<svg
ref={ref}
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="none"
className={cn("block h-full w-full", className)}
width={width}
height={height}
{...rest}
aria-hidden="true"
/>
)
}
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 (
<svg
ref={ref}
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="none"
className={cn("block h-full w-full overflow-visible", className)}
width={width}
height={height}
{...rest}
aria-hidden="true"
>
<defs>
<linearGradient id={`spark-fill-${id}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={fill} stopOpacity={0.35} />
<stop offset="100%" stopColor={fill} stopOpacity={0} />
</linearGradient>
</defs>
<path d={area} fill={`url(#spark-fill-${id})`} stroke="none" vectorEffect="non-scaling-stroke" />
<path
d={line}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth ?? 1.25}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{peak ? (
<circle
cx={peak[0]}
cy={peak[1]}
r={PEAK_R}
fill={peakColor}
stroke="var(--background)"
strokeWidth={0.5}
vectorEffect="non-scaling-stroke"
/>
) : null}
</svg>
)
},
)
Sparkline.displayName = "Sparkline"