Files
sencho/frontend/src/components/dashboard/StackHealthTable.tsx
T
Anso bb35c1bc92 feat: add sidebar update indicator toggle and Stack Health badge (#1570)
* feat: add sidebar update indicator toggle and Stack Health badge

- Add image_update_sidebar_indicators setting (default off, node-scoped)
- Gate the Updates filter chip and sidebar status indicators on the setting
- Add "Update available" badge to Stack Health table (always visible)
- Extend ImageUpdateStatus with sidebarIndicators boolean
- Poll /api/image-updates/status alongside /detail in useImageUpdates
- React to SENCHO_SETTINGS_CHANGED for instant toggle propagation
- Reset sidebar state on node switch; generation-guard stale responses
- Disable toggle when status is null (loading) or field is absent (old node)
- Wire stackUpdates through ViewRouter → HomeDashboard → StackHealthTable
- Update settings registry, operator docs, and sidebar/dashboard docs

* fix: guard against stale node renders, memo drift, and cross-node error toasts

- Track owning node ID in useImageUpdates state so React never renders
  node B with node A's data before the passive effect resets (P2)
- Replace incorrect stackUpdates dependency with sidebarStackUpdates in
  chipFilteredFiles useMemo (P3)
- Guard the error toast in handleSidebarIndicatorsChange so a stale PATCH
  failure from node A does not surface while viewing node B (P3)

* fix: default sidebar update indicators to on (opt-out)

The sidebar indicators are a safe convenience that most users want.
Switching the default from off to on matches the opt-out convention
used by prune_on_update, reclaim_hero, and health_gate_enabled.
2026-07-05 02:52:17 -04:00

299 lines
12 KiB
TypeScript

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<string, StackStatusEntry>;
metrics: MetricPoint[];
stackCpuSeries: Record<string, StackCpuSeries>;
onNavigateToStack: (stackFile: string) => void;
stackUpdates?: Record<string, StackUpdateInfo>;
}
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 <button> inside the grid
// <span> (the security ImagesTab pattern uses <TableHead>, which is invalid
// inside this CSS-grid layout, so only the sort logic is shared here).
function SortHeader({ label, k, sortKey, sortDir, onSort, align = 'left' }: {
label: string;
k: SortKey;
sortKey: SortKey | null;
sortDir: 'asc' | 'desc';
onSort: (k: SortKey) => void;
align?: 'left' | 'right';
}) {
return (
<span className={align === 'right' ? 'text-right' : undefined}>
<button
type="button"
onClick={() => onSort(k)}
className={cn(
'inline-flex items-center gap-1 uppercase tracking-[0.22em] hover:text-stat-value',
align === 'right' && 'flex-row-reverse',
)}
>
{label}
{sortKey === k && (sortDir === 'asc' ? <ArrowUp className="h-2.5 w-2.5" /> : <ArrowDown className="h-2.5 w-2.5" />)}
</button>
</span>
);
}
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<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,
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<SortKey | null>(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<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;
}
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 (
<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 (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel max-md:overflow-x-auto">
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-baseline gap-3">
<h2 className="font-heading 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'}{sortKey === null ? ' · sorted by load' : ''}
</span>
</div>
{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-[var(--density-row-x)] py-[var(--density-cell-y)] font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle`}>
<span />
<SortHeader label="STACK" k="stack" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<span>SOURCE</span>
<span>PORT</span>
<SortHeader label="UP" k="up" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} align="right" />
<SortHeader label="CPU" k="cpu" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} align="right" />
<SortHeader label="MEM" k="mem" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} align="right" />
<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-[var(--density-row-x)] py-[var(--density-row-y)] 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="flex items-center gap-2 min-w-0">
<span className="flex-1 min-w-0 truncate font-mono text-sm text-stat-value">{row.name}</span>
{row.hasUpdate && (
<span className="shrink-0 rounded-full bg-update/15 px-2 py-0.5 font-mono text-[10px] leading-none text-update tracking-wide">
Update available
</span>
)}
</span>
<span className="truncate font-mono text-[11px] uppercase tracking-wide text-stat-subtitle">
{row.source === 'git' ? 'Git' : 'Local'}
</span>
<span className="truncate font-mono text-xs tabular-nums text-stat-subtitle">
{row.mainPort !== null ? row.mainPort : '--'}
</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>
);
}