refactor(auto-update): retire per-stack gate, drive auto-update from schedules only (#1233)

* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only

The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.

The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.

Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
  accessors, the three routes under /api/stacks/*/auto-update, the per-stack
  skip in /api/auto-update/execute and SchedulerService.executeUpdate's
  fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
  Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
  rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
  optimistic toggle plumbing. Drop autoUpdateSettings state, the
  /stacks/auto-update-settings fetch, and the auto-update-settings-changed
  WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
  enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
  now means "a covering enabled action='update' schedule exists" (per-stack
  row or fleet row on this node, earliest next_run_at wins, per-stack row
  wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
  PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
  useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
  for the 60s poll. The broadcast is wrapped so a broken subscriber socket
  cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
  to describe the schedule-based model; update the matching troubleshooting
  entry. The misleading fleet-update help text in ScheduledOperationsView
  is corrected to reflect that every stack on the node is covered.

Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.

No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.

* docs: sweep remaining references to the per-stack auto-update toggle

The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:

- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
  group description, the matching screenshot alt-text, and the Skipper
  Note that listed it. Schedule task now carries the cross-link to
  Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
  refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
  "Auto-update stacks" to "Auto-update schedules" with the new value
  shape, and rewrite the troubleshooting accordion to describe the
  scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
  Stacks row and helper text to reflect that every stack on the node is
  covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
  derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
  All Stacks schedules instead of the removed per-stack policy.

The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.

No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.

* fix(stacks): drop orphaned requireAdmin import after auto-update route removal

CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.

tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
This commit is contained in:
Anso
2026-05-26 11:08:33 -04:00
committed by GitHub
parent 2a29fed117
commit adcd04b01a
36 changed files with 295 additions and 668 deletions
@@ -60,7 +60,7 @@ describe('useNotifications', () => {
it('starts with empty notifications and disconnected state', () => {
const { result } = renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
expect(result.current.notifications).toEqual([]);
expect(result.current.tickerConnected).toBe(false);
@@ -68,7 +68,7 @@ describe('useNotifications', () => {
it('opens a local notification WebSocket on mount', () => {
renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
expect(MockWS.instances.length).toBeGreaterThanOrEqual(1);
expect(MockWS.instances[0]).toBeDefined();
@@ -76,7 +76,7 @@ describe('useNotifications', () => {
it('sets tickerConnected true when local WS opens', () => {
const { result } = renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
expect(result.current.tickerConnected).toBe(true);
@@ -84,7 +84,7 @@ describe('useNotifications', () => {
it('adds notification when local WS receives notification message', () => {
const { result } = renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
act(() => {
@@ -98,7 +98,7 @@ describe('useNotifications', () => {
it('clearAllNotifications empties the local state', async () => {
const { result } = renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
act(() => {
@@ -114,9 +114,8 @@ describe('useNotifications', () => {
it('fires onImageUpdatesChange on state-invalidate with action="stack-updated"', () => {
const onStateInvalidate = vi.fn();
const onImageUpdatesChange = vi.fn();
const onAutoUpdateChange = vi.fn();
renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }),
useNotifications({ nodes: [localNode], onStateInvalidate, onImageUpdatesChange }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
act(() => {
@@ -129,14 +128,13 @@ describe('useNotifications', () => {
});
expect(onImageUpdatesChange).toHaveBeenCalledTimes(1);
expect(onStateInvalidate).toHaveBeenCalledTimes(1);
expect(onAutoUpdateChange).not.toHaveBeenCalled();
});
it('does not fire onImageUpdatesChange on a generic state-invalidate', () => {
const onStateInvalidate = vi.fn();
const onImageUpdatesChange = vi.fn();
renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange: vi.fn(), onImageUpdatesChange }),
useNotifications({ nodes: [localNode], onStateInvalidate, onImageUpdatesChange }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
act(() => {
@@ -151,29 +149,9 @@ describe('useNotifications', () => {
expect(onImageUpdatesChange).not.toHaveBeenCalled();
});
it('does not fire onImageUpdatesChange on auto-update-settings-changed', () => {
const onAutoUpdateChange = vi.fn();
const onImageUpdatesChange = vi.fn();
const onStateInvalidate = vi.fn();
renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
act(() => {
MockWS.instances[0]?.onmessage?.({
data: JSON.stringify({
type: 'state-invalidate', nodeId: 1, action: 'auto-update-settings-changed', ts: 1000,
}),
});
});
expect(onAutoUpdateChange).toHaveBeenCalledTimes(1);
expect(onImageUpdatesChange).not.toHaveBeenCalled();
expect(onStateInvalidate).not.toHaveBeenCalled();
});
it('deleteNotification removes the matching item', async () => {
const { result } = renderHook(() =>
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }),
useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }),
);
act(() => { MockWS.instances[0]?.onopen?.(); });
const notif = makeNotif({ id: 5, nodeId: localNode.id });
@@ -7,11 +7,10 @@ import type { NotificationItem } from '../../dashboard/types';
interface UseNotificationsOptions {
nodes: Node[];
onStateInvalidate: () => void;
onAutoUpdateChange: () => void;
onImageUpdatesChange: () => void;
}
export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }: UseNotificationsOptions) {
export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }: UseNotificationsOptions) {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [tickerConnected, setTickerConnected] = useState(false);
@@ -22,8 +21,6 @@ export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange,
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
const onStateInvalidateRef = useRef(onStateInvalidate);
onStateInvalidateRef.current = onStateInvalidate;
const onAutoUpdateChangeRef = useRef(onAutoUpdateChange);
onAutoUpdateChangeRef.current = onAutoUpdateChange;
const onImageUpdatesChangeRef = useRef(onImageUpdatesChange);
onImageUpdatesChangeRef.current = onImageUpdatesChange;
@@ -98,13 +95,9 @@ export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange,
setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp));
} else if (msg.type === 'state-invalidate') {
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg }));
if (msg.action === 'auto-update-settings-changed') {
onAutoUpdateChangeRef.current();
} else {
onStateInvalidateRef.current();
if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
onImageUpdatesChangeRef.current();
}
onStateInvalidateRef.current();
if (msg.scope === 'image-updates' && msg.action === 'stack-updated') {
onImageUpdatesChangeRef.current();
}
}
} catch (e) {
@@ -52,7 +52,6 @@ export function useSidebarContextMenu({
labels: stackListState.labels,
assignedLabelIds: (stackListState.stackLabelMap[file] ?? []).map(l => l.id),
menuVisibility: stackActions.getStackMenuVisibility(file),
autoUpdateEnabled: stackListState.autoUpdateSettings[sName] ?? true,
openAlertSheet: () => overlayState.openAlertSheet(file),
openAutoHeal: () => overlayState.openAutoHeal(file),
checkUpdates: () => stackActions.checkUpdatesForStack(),
@@ -64,22 +63,6 @@ export function useSidebarContextMenu({
remove: () => overlayState.openDeleteDialog(sName),
pin: () => stackListState.pin(file),
unpin: () => stackListState.unpin(file),
setAutoUpdateEnabled: async (enabled: boolean) => {
stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: enabled }));
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(sName)}/auto-update`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string })?.error || 'Failed to update auto-update setting.');
}
} catch (err: unknown) {
stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: !enabled }));
toast.error((err as Error)?.message || 'Failed to update auto-update setting.');
}
},
toggleLabel: async (labelId: number) => {
const currentIds = (stackListState.stackLabelMap[file] ?? []).map(l => l.id);
const assigned = currentIds.includes(labelId);
@@ -142,7 +125,7 @@ export function useSidebarContextMenu({
}, [
stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral,
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
stackListState.autoUpdateSettings, stackListState.pin, stackListState.unpin,
stackListState.pin, stackListState.unpin,
]);
return buildMenuCtx;
@@ -44,7 +44,6 @@ export function useStackListState() {
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [autoUpdateSettings, setAutoUpdateSettings] = useState<Record<string, boolean>>({});
const [filterChip, setFilterChip] = useState<FilterChip>('all');
const [bulkMode, setBulkMode] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
@@ -181,20 +180,6 @@ export function useStackListState() {
const refreshStacksRef = useRef(refreshStacks);
useEffect(() => { refreshStacksRef.current = refreshStacks; });
const fetchAutoUpdateSettings = async () => {
try {
const res = await apiFetch('/stacks/auto-update-settings');
if (res.ok) {
const data = await res.json();
setAutoUpdateSettings(data as Record<string, boolean>);
} else {
console.error('[AutoUpdateSettings] fetch returned', res.status);
}
} catch (e: unknown) {
console.error('[AutoUpdateSettings] fetch failed:', e);
}
};
const handleScanStacks = async () => {
if (isScanning) return;
setIsScanning(true);
@@ -339,7 +324,6 @@ export function useStackListState() {
stackPorts, setStackPorts,
labels,
stackLabelMap,
autoUpdateSettings, setAutoUpdateSettings,
filterChip, setFilterChip,
bulkMode, setBulkMode,
selectedFiles, setSelectedFiles,
@@ -351,7 +335,6 @@ export function useStackListState() {
setOptimisticStatus,
refreshLabels,
refreshStacks,
fetchAutoUpdateSettings,
handleScanStacks,
scheduleStateInvalidateRefresh,
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,