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
+5 -1
View File
@@ -128,6 +128,8 @@ export default function EditorLayout() {
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
stackUpdates,
fetchImageUpdates,
sidebarIndicators,
sidebarStackUpdates,
pinned,
isCollapsed, toggleCollapse,
remoteSearchLoading,
@@ -669,7 +671,7 @@ export default function EditorLayout() {
stackLabelMap,
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
stackCounts,
stackUpdates,
stackUpdates: sidebarStackUpdates,
gitSourcePendingMap,
pinnedFiles: pinned,
isCollapsed,
@@ -697,6 +699,7 @@ export default function EditorLayout() {
onToggleSelect={toggleSelect}
onClearSelection={clearSelection}
onBulkAction={handleBulkAction}
showUpdatesChip={sidebarIndicators}
/>
);
@@ -777,6 +780,7 @@ export default function EditorLayout() {
fleetTab={fleetTab}
onFleetTabConsumed={() => setFleetTab(null)}
renderEditor={renderEditor}
stackUpdates={stackUpdates}
/>
</div>
);
@@ -14,6 +14,7 @@ import type { NotificationItem } from '../dashboard/types';
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
import type { MuteRuleDraft } from '@/lib/muteRules';
import type { ActiveView } from './hooks/useViewNavigationState';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import type { SecurityTab, FleetTab } from '@/lib/events';
// Paid-tier views are loaded on demand. Their internal PaidGate /
@@ -99,6 +100,7 @@ export interface ViewRouterProps {
// (large) editor JSX is only allocated when activeView === 'editor',
// not on every parent render that lands on a different view.
renderEditor: () => ReactNode;
stackUpdates: Record<string, StackUpdateInfo>;
}
export function ViewRouter({
@@ -128,6 +130,7 @@ export function ViewRouter({
fleetTab,
onFleetTabConsumed,
renderEditor,
stackUpdates,
}: ViewRouterProps): ReactNode {
const { can } = useAuth();
if (activeView === 'settings') {
@@ -251,6 +254,7 @@ export function ViewRouter({
onOpenSettingsSection={onOpenSettingsSection}
notifications={notifications}
onClearNotifications={onClearNotifications}
stackUpdates={stackUpdates}
/>
);
}
@@ -8,6 +8,7 @@ import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions';
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
import type { StackAction, StackActionResult } from '../EditorView';
import type { Label as StackLabel } from '../../label-types';
@@ -60,6 +61,8 @@ export interface RemoteResult {
files: Array<{ file: string; status: StackRowStatus }>;
}
const EMPTY_UPDATES: Record<string, StackUpdateInfo> = {};
export function useStackListState() {
const { nodes, activeNode } = useNodes();
@@ -96,7 +99,8 @@ export function useStackListState() {
const [bulkMode, setBulkMode] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id);
const { stackUpdates, refresh: fetchImageUpdates, sidebarIndicators } = useImageUpdates(activeNode?.id);
const sidebarStackUpdates = sidebarIndicators ? stackUpdates : EMPTY_UPDATES;
const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id);
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
const { runBulk } = useBulkStackActions();
@@ -295,16 +299,16 @@ export function useStackListState() {
all: filteredFiles.length,
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
updates: filteredFiles.filter(f => stackUpdates[f]?.hasUpdate).length,
}), [filteredFiles, stackStatuses, stackUpdates]);
updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length,
}), [filteredFiles, stackStatuses, sidebarStackUpdates]);
const chipFilteredFiles = useMemo(() => {
if (filterChip === 'all') return filteredFiles;
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
if (filterChip === 'updates') return filteredFiles.filter(f => stackUpdates[f]?.hasUpdate);
if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate);
return filteredFiles;
}, [filteredFiles, filterChip, stackStatuses, stackUpdates]);
}, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]);
const toggleBulkMode = useCallback(() => {
setBulkMode(prev => {
@@ -381,6 +385,15 @@ export function useStackListState() {
});
}, [remoteStackResults, nodes]);
// When the sidebar indicator toggle is turned off, reset an active Updates
// filter to 'all' so the user is not stuck in a filter that shows nothing.
useEffect(() => {
if (!sidebarIndicators && filterChip === 'updates') {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFilterChip('all');
}
}, [sidebarIndicators, filterChip]);
return {
files, setFiles, filesNodeId,
selectedFile, setSelectedFile,
@@ -410,6 +423,7 @@ export function useStackListState() {
scheduleStateInvalidateRefresh,
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
stackUpdates, fetchImageUpdates,
sidebarIndicators, sidebarStackUpdates,
pinned, pin, unpin, isPinned,
isCollapsed, toggleCollapse,
remoteSearchLoading,
+4 -1
View File
@@ -1,6 +1,7 @@
import { useNodes } from '@/context/NodeContext';
import type { NotificationItem } from './dashboard/types';
import type { SectionId } from './settings/types';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import {
HealthStatusBar,
ResourceGauges,
@@ -16,11 +17,12 @@ interface HomeDashboardProps {
onOpenSettingsSection?: (section: SectionId) => void;
notifications: NotificationItem[];
onClearNotifications: () => void | Promise<void>;
stackUpdates?: Record<string, StackUpdateInfo>;
}
const NOOP = () => {};
export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications }: HomeDashboardProps) {
export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications, stackUpdates = {} }: HomeDashboardProps) {
const { activeNode, nodes } = useNodes();
const data = useDashboardData();
const activeNodeName = activeNode?.name || 'Local';
@@ -49,6 +51,7 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
metrics={data.metrics}
stackCpuSeries={data.stackCpuSeries}
onNavigateToStack={onNavigateToStack ?? NOOP}
stackUpdates={stackUpdates}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
@@ -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>
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import { TogglePill } from '@/components/ui/toggle-pill';
import {
Select,
SelectContent,
@@ -12,6 +13,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control';
import { SettingsPrimaryButton } from './SettingsActions';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { formatTimeAgo, formatTimeUntil } from '@/lib/relativeTime';
@@ -61,6 +63,49 @@ export function UpdatesSection() {
const intervalMinutes = status?.intervalMinutes ?? null;
// Mirror activeNode.id in a ref so the PATCH handler can detect a node
// switch mid-flight and discard a stale write.
const activeNodeIdRef = useRef(activeNode?.id ?? null);
activeNodeIdRef.current = activeNode?.id ?? null;
// Derive toggle state from the current status. When the field is missing
// (older remote node) the toggle is disabled with a helpful message.
const sidebarIndicators = status?.sidebarIndicators ?? false;
const nodeSupportsSidebarSetting = status !== null && status.sidebarIndicators !== undefined;
const handleSidebarIndicatorsChange = useCallback(async (next: boolean) => {
const targetNodeId = activeNodeIdRef.current;
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
nodeId: targetNodeId ?? null,
body: JSON.stringify({ image_update_sidebar_indicators: next ? '1' : '0' }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to update setting');
}
// Guard: if the active node changed while the PATCH was in flight,
// discard the response — it belongs to a different node.
if (activeNodeIdRef.current === targetNodeId) {
setStatus(prev => prev ? { ...prev, sidebarIndicators: next } : prev);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: ['image_update_sidebar_indicators'] },
}));
}
} catch (e) {
// Only surface the error if the active node hasn't changed. A
// stale failure from node A must not toast while the user views
// node B.
if (activeNodeIdRef.current === targetNodeId) {
toast.error((e as Error)?.message || 'Failed to update sidebar indicator setting.');
}
} finally {
setIsSaving(false);
}
}, []);
useMastheadStats(
isLoading || intervalMinutes == null
? null
@@ -70,6 +115,7 @@ export function UpdatesSection() {
useEffect(() => {
let cancelled = false;
const fetchStatus = async () => {
setStatus(null);
setIsLoading(true);
try {
const res = await apiFetch('/image-updates/status');
@@ -280,6 +326,25 @@ export function UpdatesSection() {
</div>
</SettingsField>
</SettingsSection>
<SettingsSection title="Sidebar" kicker="node-scoped">
<SettingsField
label="Show update status in sidebar"
helper={
status !== null && status.sidebarIndicators === undefined
? "This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it."
: "Show a pulsing dot when a stack has an available update and a warning icon when the check fails. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected."
}
htmlFor="sidebar-indicators-toggle"
>
<TogglePill
id="sidebar-indicators-toggle"
checked={sidebarIndicators}
onChange={handleSidebarIndicatorsChange}
disabled={status === null || !nodeSupportsSidebarSetting || readOnly || isSaving}
/>
</SettingsField>
</SettingsSection>
</fieldset>
);
}
+1 -1
View File
@@ -237,7 +237,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
group: 'automation',
label: 'Image update checks',
description: 'How often this node polls registries to detect available image updates and raise notifications.',
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck'],
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck', 'sidebar', 'badge', 'dot', 'indicator', 'status'],
tier: null,
scope: 'node',
},
@@ -20,6 +20,7 @@ export interface PatchableSettings {
health_gate_enabled?: '0' | '1';
health_gate_window_seconds?: string;
env_block_deploy_on_missing_required?: '0' | '1';
image_update_sidebar_indicators?: '0' | '1';
}
export const DEFAULT_SETTINGS: PatchableSettings = {
@@ -44,6 +45,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
health_gate_enabled: '1',
health_gate_window_seconds: '90',
env_block_deploy_on_missing_required: '0',
image_update_sidebar_indicators: '1',
};
export type SectionId =
@@ -15,6 +15,7 @@ interface SidebarFilterChipsProps {
onChange: (chip: FilterChip) => void;
visible: boolean;
onToggle: () => void;
showUpdatesChip?: boolean;
}
const chips: { id: FilterChip; label: string }[] = [
@@ -24,12 +25,13 @@ const chips: { id: FilterChip; label: string }[] = [
{ id: 'updates', label: 'Updates' },
];
export function SidebarFilterChips({ active, counts, onChange, visible, onToggle }: SidebarFilterChipsProps) {
export function SidebarFilterChips({ active, counts, onChange, visible, onToggle, showUpdatesChip = true }: SidebarFilterChipsProps) {
const visibleChips = showUpdatesChip ? chips : chips.filter(c => c.id !== 'updates');
return (
<div className="flex items-center pb-1.5 pt-0.5 pl-2">
{visible ? (
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
{chips.map(({ id, label }) => {
{visibleChips.map(({ id, label }) => {
const count = counts[id];
const displayCount = count > 99 ? '99+' : count;
const isActive = active === id;
@@ -33,6 +33,7 @@ export interface StackSidebarProps {
onToggleSelect: (file: string) => void;
onClearSelection: () => void;
onBulkAction: (action: BulkAction) => void;
showUpdatesChip?: boolean;
}
export function StackSidebar(props: StackSidebarProps) {
@@ -41,6 +42,7 @@ export function StackSidebar(props: StackSidebarProps) {
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
list, activitySummary, onActivityAction,
bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
showUpdatesChip = true,
} = props;
const [filtersVisible, setFiltersVisible] = useState(() => {
@@ -84,6 +86,7 @@ export function StackSidebar(props: StackSidebarProps) {
onChange={onFilterChipChange}
visible={filtersVisible}
onToggle={handleToggleFilters}
showUpdatesChip={showUpdatesChip}
/>
{selectedFiles.size > 0 && (
<SidebarBulkBar