diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index e642975d..d4b83435 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -32,6 +32,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'health_gate_enabled', 'health_gate_window_seconds', 'env_block_deploy_on_missing_required', + 'image_update_sidebar_indicators', ]); // Keys whose write requires a paid license, not just an admin role. @@ -62,6 +63,7 @@ const SettingsPatchSchema = z.object({ health_gate_enabled: z.enum(['0', '1']), health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String), env_block_deploy_on_missing_required: z.enum(['0', '1']), + image_update_sidebar_indicators: z.enum(['0', '1']), }).partial(); export const settingsRouter = Router(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index b09d810c..88346d3c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1665,6 +1665,7 @@ export class DatabaseService { stmt.run('image_update_check_interval_minutes', '120'); stmt.run('image_update_check_mode', 'interval'); stmt.run('image_update_check_cron', ''); + stmt.run('image_update_sidebar_indicators', '1'); stmt.run('env_block_deploy_on_missing_required', '0'); // Seed the default local node if none exists diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 661744e7..d0d4257b 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -47,6 +47,7 @@ export interface ImageUpdateStatus { manualCooldownRemainingMs: number; mode: 'interval' | 'cron'; cronExpression: string | null; + sidebarIndicators: boolean; } // ─── Compose file helpers ──────────────────────────────────────────────────── @@ -390,6 +391,13 @@ export class ImageUpdateService { } public getStatus(): ImageUpdateStatus { + let sidebarIndicators = false; + try { + const settings = DatabaseService.getInstance().getGlobalSettings(); + sidebarIndicators = settings.image_update_sidebar_indicators === '1'; + } catch (e) { + console.warn('[ImageUpdateService] Failed to read sidebar indicator setting:', e); + } return { checking: this.isRunning, intervalMinutes: Math.round(this.intervalMs / (60 * 1000)), @@ -399,6 +407,7 @@ export class ImageUpdateService { manualCooldownRemainingMs: this.getManualCooldownRemainingMs(), mode: this.mode, cronExpression: this.cronExpression, + sidebarIndicators, }; } diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 0baa7984..ae6ffeb5 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -62,7 +62,7 @@ A mono table of every stack discovered in the active node's `COMPOSE_DIR`, sorte | Column | Description | |--------|-------------| | **Status dot** | Green when the stack is running and its 10-minute peak CPU is under 80%, amber when peak CPU is at or above 80%, rose when any container has exited or peak CPU is at or above 90% | -| **STACK** | Stack name, derived from the compose file (extension stripped) | +| **STACK** | Stack name with an orange "Update available" badge when a newer image has been detected. The badge appears regardless of the sidebar indicator setting. | | **HOST** | Active node this stack belongs to | | **UP** | How long the oldest running container has been up, in compact units (`s` / `m` / `h` / `d`); a stopped or never-started stack reads `--` | | **CPU** | Latest aggregate CPU across the stack's containers | diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx index d552be3d..385c81ae 100644 --- a/docs/features/sidebar.mdx +++ b/docs/features/sidebar.mdx @@ -28,7 +28,7 @@ Four chips sit below the search box. Each shows a live count to the right of its - **All**: every stack on the node. - **Up**: stacks that are running with nothing crashed. A stack whose only stopped container finished cleanly (an init job that exited without error) still counts as up. - **Down**: stacks that need attention, whether fully stopped or running with at least one crashed container (the `PT` state described below). -- **Updates**: stacks with at least one image update available. The chip renders in orange when the count is non-zero so you can spot pending updates at a glance. +- **Updates**: stacks with at least one image update available. The chip renders in orange when the count is non-zero so you can spot pending updates at a glance. The Updates chip and the trailing update indicators on stack rows are controlled by the Image Update Checks [sidebar setting](/reference/settings#image-update-checks---sidebar). When the setting is off, the chip and indicators are hidden. Filter chip row showing ALL (15), Up (15), Down (0), and Updates (1) with the Updates chip highlighted in orange and a collapse toggle icon on the right diff --git a/docs/openapi.yaml b/docs/openapi.yaml index eb731ed9..ee280f22 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -686,6 +686,9 @@ components: cronExpression: type: ["string", "null"] description: 5-field cron expression when mode is 'cron', null otherwise. + sidebarIndicators: + type: boolean + description: Whether sidebar update-status indicators are enabled. Controlled by the `image_update_sidebar_indicators` global setting. Default is `false`. responses: Unauthorized: diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 362a15ac..6432e5bb 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -465,6 +465,16 @@ Configure how often this node polls container registries to detect available ima The section footer shows the last-checked timestamp and when the next check is scheduled. +### Sidebar + +| Setting | Default | Description | +|---------|---------|-------------| +| **Show update status in sidebar** | On | When on, the sidebar shows a pulsing dot on stacks with an available update, a warning icon when a check fails, and an Updates filter chip. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected. | + + + Nodes running older versions of Sencho do not expose this setting. Upgrade the node to enable the toggle. + + --- ## Webhooks diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 2e69e388..a2d30b4f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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, 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} /> ); diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 36bc0295..7979ab86 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -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; } 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} /> ); } diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts index 668d3931..db36f41b 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -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 = {}; + export function useStackListState() { const { nodes, activeNode } = useNodes(); @@ -96,7 +99,8 @@ export function useStackListState() { const [bulkMode, setBulkMode] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(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, diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 06ef0446..60323cec 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -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; + stackUpdates?: Record; } 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} />
diff --git a/frontend/src/components/dashboard/StackHealthTable.tsx b/frontend/src/components/dashboard/StackHealthTable.tsx index 5172b5cd..ce8f40c7 100644 --- a/frontend/src/components/dashboard/StackHealthTable.tsx +++ b/frontend/src/components/dashboard/StackHealthTable.tsx @@ -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; onNavigateToStack: (stackFile: string) => void; + stackUpdates?: Record; } 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]}`} >
+ + + + + + ); } diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 7cb82c13..ce2d7203 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -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', }, diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 06fe6288..efbeba45 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -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 = diff --git a/frontend/src/components/sidebar/SidebarFilterChips.tsx b/frontend/src/components/sidebar/SidebarFilterChips.tsx index 735f9484..22ea33cb 100644 --- a/frontend/src/components/sidebar/SidebarFilterChips.tsx +++ b/frontend/src/components/sidebar/SidebarFilterChips.tsx @@ -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 (
{visible ? (
- {chips.map(({ id, label }) => { + {visibleChips.map(({ id, label }) => { const count = counts[id]; const displayCount = count > 99 ? '99+' : count; const isActive = active === id; diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index 31ebcbf3..c5cc1d6f 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -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 && ( >({}); + const [sidebarIndicators, setSidebarIndicators] = useState(false); + + // Track which node owns the current state. When activeNodeId changes + // React renders once with the old owner before the passive effect clears + // the data. Returning empty defaults when the IDs mismatch prevents a + // single-frame flash of the wrong node's data. + const [ownerNodeId, setOwnerNodeId] = useState(activeNodeId); + + // Generation counter: every activeNodeId change increments it, and every + // await is gated against it so a slow response from a previous node is + // discarded. + const genRef = useRef(0); const refresh = useCallback(async () => { - try { - const res = await apiFetch('/image-updates/detail'); - if (res.ok) { - setStackUpdates(await res.json() as Record); - return; - } - // A remote node on an older Sencho lacks /detail; fall back to the boolean - // map so update badges keep working until that node is upgraded. - if (res.status === 404) { - const boolRes = await apiFetch('/image-updates'); - if (boolRes.ok) { - const bool = await boolRes.json() as Record; - const synthesized: Record = {}; - for (const [stack, hasUpdate] of Object.entries(bool)) { - synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 }; - } - setStackUpdates(synthesized); + const gen = ++genRef.current; + const targetNodeId = activeNodeId ?? null; + + // Self-contained status helper: owns fetch, parse, and state write. + // A failure here never blocks the detail path below. + const fetchStatus = async (): Promise => { + try { + const res = await apiFetch('/image-updates/status', { nodeId: targetNodeId }); + if (genRef.current !== gen) return; + if (res.ok) { + const data = await res.json() as ImageUpdateStatus; + if (genRef.current !== gen) return; + setSidebarIndicators(data.sidebarIndicators ?? false); } else { - console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status); + console.error('[ImageUpdates] status fetch returned', res.status); } - return; + } catch (e) { + console.error('[ImageUpdates] status fetch failed:', e); } - // Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep - // the last-known state on screen, but do not let the failure go silent. - console.error('[ImageUpdates] /image-updates/detail returned', res.status); - } catch (e: unknown) { - console.error('[ImageUpdates] fetch failed:', e); - } - }, []); + }; + + // Self-contained detail helper: owns fetch, parse, 404 fallback, and + // state write. A failure here never blocks the status path above. + const fetchDetail = async (): Promise => { + try { + const res = await apiFetch('/image-updates/detail', { nodeId: targetNodeId }); + if (genRef.current !== gen) return; + if (res.ok) { + const data = await res.json() as Record; + if (genRef.current !== gen) return; + setStackUpdates(data); + return; + } + // A remote node on an older Sencho lacks /detail; fall back to the boolean + // map so update badges keep working until that node is upgraded. + if (res.status === 404) { + const boolRes = await apiFetch('/image-updates', { nodeId: targetNodeId }); + if (genRef.current !== gen) return; + if (boolRes.ok) { + const bool = await boolRes.json() as Record; + if (genRef.current !== gen) return; + const synthesized: Record = {}; + for (const [stack, hasUpdate] of Object.entries(bool)) { + synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 }; + } + setStackUpdates(synthesized); + } else { + console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status); + } + return; + } + // Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep + // the last-known state on screen, but do not let the failure go silent. + console.error('[ImageUpdates] /image-updates/detail returned', res.status); + } catch (e: unknown) { + console.error('[ImageUpdates] fetch failed:', e); + } + }; + + await Promise.allSettled([fetchStatus(), fetchDetail()]); + }, [activeNodeId]); // Pin the interval to the latest closure without retriggering it on // every render the way putting `refresh` into the deps array would. const refreshRef = useRef(refresh); refreshRef.current = refresh; + // Poll on mount and on node change. Reset state and capture the owning + // node BEFORE fetching so the old node's data is cleared before the new + // node's first response arrives, and the guard above returns empty defaults + // on the render before this effect fires. useEffect(() => { + genRef.current += 1; + setStackUpdates({}); // eslint-disable-line react-hooks/set-state-in-effect + setSidebarIndicators(false); // eslint-disable-line react-hooks/set-state-in-effect + setOwnerNodeId(activeNodeId); // eslint-disable-line react-hooks/set-state-in-effect void refreshRef.current(); const id = setInterval(() => { void refreshRef.current(); }, IMAGE_UPDATE_POLL_MS); return () => clearInterval(id); }, [activeNodeId]); - return { stackUpdates, refresh }; + // React to settings changes so toggling the sidebar-indicator preference + // propagates immediately without waiting for the 5-minute poll. + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ changedKeys?: string[] }>).detail; + if (detail?.changedKeys?.includes('image_update_sidebar_indicators')) { + refreshRef.current(); + } + }; + window.addEventListener(SENCHO_SETTINGS_CHANGED, handler); + return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler); + }, []); + + // Return empty defaults until the owning node matches the active node. + // This prevents React from rendering node B with node A's update data and + // sidebar preference during the single frame before the passive effect fires. + const isOwner = activeNodeId !== undefined && activeNodeId === ownerNodeId; + return { + stackUpdates: isOwner ? stackUpdates : {} as Record, + refresh, + sidebarIndicators: isOwner ? sidebarIndicators : false, + }; } diff --git a/frontend/src/types/imageUpdates.ts b/frontend/src/types/imageUpdates.ts index 9fe4701e..0097768a 100644 --- a/frontend/src/types/imageUpdates.ts +++ b/frontend/src/types/imageUpdates.ts @@ -22,6 +22,8 @@ export interface ImageUpdateStatus { mode: 'interval' | 'cron'; /** 5-field cron expression when mode is 'cron', null otherwise. */ cronExpression: string | null; + /** Whether sidebar update-status indicators are enabled. Optional for older-node compatibility. */ + sidebarIndicators?: boolean; } /**