mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 00:18:00 +00:00
bb35c1bc92
* 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.
299 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|