import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Sparkline } from '@/components/ui/sparkline'; import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types'; import type { StackUpdateInfo } from '@/types/imageUpdates'; import { aggregateCurrentUsage } from './aggregateCurrentUsage'; import { classifyRow, type RowState } from './classifyRow'; interface StackHealthTableProps { stackStatuses: Record; metrics: MetricPoint[]; stackCpuSeries: Record; onNavigateToStack: (stackFile: string) => void; stackUpdates?: Record; } type SortKey = 'stack' | 'up' | 'cpu' | 'mem'; const PAGE_SIZE = 8; // Shared by the header and data rows so their columns stay aligned. The // `max-md:min-w` keeps both at the same width below md, where the card scrolls // horizontally; desktop is unaffected by the `max-md:` prefix. Columns: // dot · STACK · SOURCE · PORT · UP · CPU · MEM · CPU·10m · chevron. const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_64px_56px_52px_52px_72px_110px_16px] max-md:min-w-[640px]'; const formatMemory = (mb: number): string => { if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; return `${mb.toFixed(0)} MB`; }; // Grid-compatible sortable header cell. Renders a ); } 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 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, onNavigateToStack, stackUpdates = {}, }: StackHealthTableProps) { const [page, setPage] = useState(0); // null = the default health-state ordering (worst first); a SortKey switches // to user-driven column sort. const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); // 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(() => aggregateCurrentUsage(metrics), [metrics]); const baseRows = useMemo(() => { return 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, source: entry.source ?? 'local', mainPort: entry.mainPort ?? null, hasUpdate: stackUpdates[file]?.hasUpdate ?? false, }; }); }, [stackStatuses, stackAggregates, stackCpuSeries, stackUpdates]); const rows = useMemo(() => { const list = [...baseRows]; if (sortKey === 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; } const dir = sortDir === 'asc' ? 1 : -1; const nowSecs = now / 1000; const uptime = (rs: number | null) => (rs !== null ? nowSecs - rs : -1); list.sort((a, b) => { switch (sortKey) { case 'stack': return a.name.localeCompare(b.name) * dir; case 'up': return (uptime(a.runningSince) - uptime(b.runningSince)) * dir; case 'cpu': return ((a.cpu ?? -1) - (b.cpu ?? -1)) * dir; case 'mem': return ((a.memory ?? -1) - (b.memory ?? -1)) * dir; // Exhaustive: a new SortKey must add a case or this fails to compile. default: { const _exhaustive: never = sortKey; return _exhaustive; } } }); return list; }, [baseRows, sortKey, sortDir, now]); const toggleSort = (key: SortKey) => { if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); else { setSortKey(key); setSortDir(key === 'stack' ? 'asc' : 'desc'); } setPage(0); }; const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); 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 stackCount = Object.keys(stackStatuses).length; if (stackCount === 0) { return (

No stacks found. Create one from the sidebar.

); } return (

Stack health

{stackCount} {stackCount === 1 ? 'stack' : 'stacks'}{sortKey === null ? ' · sorted by load' : ''}
{needsPagination ? (
{safePage + 1} / {totalPages}
) : null}
SOURCE PORT 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-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`} >
  • ))}
); }