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.
This commit is contained in:
Anso
2026-07-05 02:52:17 -04:00
committed by GitHub
parent c677b8bb66
commit bb35c1bc92
19 changed files with 256 additions and 47 deletions
@@ -4,6 +4,7 @@ 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';
@@ -12,6 +13,7 @@ interface StackHealthTableProps {
metrics: MetricPoint[];
stackCpuSeries: Record<string, StackCpuSeries>;
onNavigateToStack: (stackFile: string) => void;
stackUpdates?: Record<string, StackUpdateInfo>;
}
type SortKey = 'stack' | 'up' | 'cpu' | 'mem';
@@ -90,6 +92,7 @@ export function StackHealthTable({
metrics,
stackCpuSeries,
onNavigateToStack,
stackUpdates = {},
}: StackHealthTableProps) {
const [page, setPage] = useState(0);
// null = the default health-state ordering (worst first); a SortKey switches
@@ -127,9 +130,10 @@ export function StackHealthTable({
runningSince: entry.runningSince ?? null,
source: entry.source ?? 'local',
mainPort: entry.mainPort ?? null,
hasUpdate: stackUpdates[file]?.hasUpdate ?? false,
};
});
}, [stackStatuses, stackAggregates, stackCpuSeries]);
}, [stackStatuses, stackAggregates, stackCpuSeries, stackUpdates]);
const rows = useMemo(() => {
const list = [...baseRows];
@@ -246,7 +250,14 @@ export function StackHealthTable({
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="truncate font-mono text-sm text-stat-value">{row.name}</span>
<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>