;
}
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]}`}
>
- {row.name}
+
+ {row.name}
+ {row.hasUpdate && (
+
+ Update available
+
+ )}
+
{row.source === 'git' ? 'Git' : 'Local'}
diff --git a/frontend/src/components/settings/UpdatesSection.tsx b/frontend/src/components/settings/UpdatesSection.tsx
index c31e68a8..764f318f 100644
--- a/frontend/src/components/settings/UpdatesSection.tsx
+++ b/frontend/src/components/settings/UpdatesSection.tsx
@@ -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() {
+
+
+
+
+
+
);
}
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;
}
/**