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