import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Sparkline } from '@/components/ui/sparkline'; import { ChevronLeft, ChevronRight, Layers } from 'lucide-react'; import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types'; interface StackHealthTableProps { stackStatuses: Record; metrics: MetricPoint[]; stackCpuSeries: Record; activeNodeName: string; onNavigateToStack: (stackFile: string) => void; } const PAGE_SIZE = 8; const WARN = 80; const CRIT = 90; const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_minmax(0,120px)_52px_52px_72px_110px_16px]'; const formatMemory = (mb: number): string => { if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; return `${mb.toFixed(0)} MB`; }; 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`; } type RowState = 'healthy' | 'warn' | 'error'; function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState { if (status === 'exited') return 'error'; if (peakCpu >= CRIT) return 'error'; if (peakCpu >= WARN) return 'warn'; return 'healthy'; } const stateDot: Record = { healthy: 'bg-success', warn: 'bg-warning', error: 'bg-destructive', }; const rowTint: Record = { healthy: '', warn: 'bg-warning/[0.04]', error: 'bg-destructive/[0.04]', }; const sparkStroke: Record = { healthy: 'var(--chart-1)', warn: 'var(--warning)', error: 'var(--destructive)', }; export function StackHealthTable({ stackStatuses, metrics, stackCpuSeries, activeNodeName, onNavigateToStack, }: StackHealthTableProps) { const [page, setPage] = useState(0); // Live-tick the current second so uptime labels advance without a parent // refetch. Thirty-second cadence keeps the DOM calm while still refreshing // every "Nm" bucket change. const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 30000); return () => clearInterval(id); }, []); const stackAggregates = useMemo(() => { const latestPerContainer: Record> = {}; for (const m of metrics) { if (!m.stack_name) continue; 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[m.stack_name][m.container_id] = m; } } const result: Record = {}; for (const [stack, containers] of Object.entries(latestPerContainer)) { let mem = 0; let cpu = 0; for (const m of Object.values(containers)) { mem += m.memory_mb; cpu += m.cpu_percent; } result[stack] = { mem, cpu }; } return result; }, [metrics]); const rows = useMemo(() => { const list = Object.entries(stackStatuses).map(([file, entry]) => { const name = file.replace(/\.(yml|yaml)$/, ''); const agg = stackAggregates[name]; const series = stackCpuSeries[name]; const peakCpu = series?.peakValue ?? agg?.cpu ?? 0; const state = classifyRow(entry.status, peakCpu); return { file, name, status: entry.status, memory: agg?.mem ?? null, cpu: agg?.cpu ?? null, peakCpu, series: series?.points ?? [], peakIndex: series?.peakIndex ?? -1, state, runningSince: entry.runningSince ?? null, }; }); const stateOrder: Record = { error: 0, warn: 1, healthy: 2 }; list.sort((a, b) => { const diff = stateOrder[a.state] - stateOrder[b.state]; if (diff !== 0) return diff; return b.peakCpu - a.peakCpu; }); return list; }, [stackStatuses, stackAggregates, stackCpuSeries]); const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); 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'} · sorted by load
{needsPagination ? (
{safePage + 1} / {totalPages}
) : null}
STACK HOST UP CPU MEM CPU · 10m
    {pagedRows.map((row) => (
  • onNavigateToStack(row.file)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNavigateToStack(row.file); } }} className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`} >
  • ))}
); }