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