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
@@ -18,7 +18,6 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
labels: [],
assignedLabelIds: [],
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false },
autoUpdateEnabled: true,
openAlertSheet: vi.fn(),
openAutoHeal: vi.fn(),
checkUpdates: vi.fn(),
@@ -33,7 +32,6 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
toggleLabel: vi.fn(),
createAndAssignLabel: vi.fn(),
openLabelManager: vi.fn(),
setAutoUpdateEnabled: vi.fn(),
openScheduleTask: vi.fn(),
...overrides,
};
@@ -85,16 +83,15 @@ describe('useStackMenuItems', () => {
expect(del.icon).toBe(Trash2);
});
it('shows auto-update toggle in inspect when isPaid', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true })));
const inspect = result.current.find(g => g.id === 'inspect')!;
expect(inspect.items.find(i => i.id === 'auto-update')).toBeDefined();
});
it('hides auto-update toggle when !isPaid', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false })));
const inspect = result.current.find(g => g.id === 'inspect')!;
expect(inspect.items.find(i => i.id === 'auto-update')).toBeUndefined();
it('does not show an auto-update entry; Schedule task is the auto-update path', () => {
const paid = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true })));
const community = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false })));
for (const r of [paid, community]) {
const groups = r.result.current;
expect(groups.some(g => g.items.some(i => i.id === 'auto-update'))).toBe(false);
}
const lifecycle = paid.result.current.find(g => g.id === 'lifecycle')!;
expect(lifecycle.items.some(i => i.id === 'schedule')).toBe(true);
});
it('keeps label assignment available when !isPaid', () => {
@@ -118,16 +115,6 @@ describe('useStackMenuItems', () => {
expect(organize.items.find(i => i.id === 'pin')).toBeDefined();
});
it('auto-update toggle calls setAutoUpdateEnabled with toggled value', () => {
const setAutoUpdateEnabled = vi.fn();
const { result } = renderHook(() =>
useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true, setAutoUpdateEnabled }))
);
const inspect = result.current.find(g => g.id === 'inspect')!;
inspect.items.find(i => i.id === 'auto-update')!.onSelect();
expect(setAutoUpdateEnabled).toHaveBeenCalledWith(false);
});
it('lifecycle items follow menuVisibility flags', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true },
+1 -9
View File
@@ -4,7 +4,6 @@ import {
ArrowUpRight,
BellRing,
CalendarClock,
CircleSlash,
Download,
Pin,
PinOff,
@@ -22,7 +21,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled, openScheduleTask,
menuVisibility, openScheduleTask,
} = ctx;
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
@@ -34,12 +33,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
];
if (isPaid) {
inspect.push({ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal });
inspect.push({
id: 'auto-update',
label: autoUpdateEnabled ? 'Auto-update: Enabled' : 'Auto-update: Disabled',
icon: autoUpdateEnabled ? RefreshCw : CircleSlash,
onSelect: () => setAutoUpdateEnabled(!autoUpdateEnabled),
});
}
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
if (stackStatus === 'running' && hasPort) {
@@ -89,7 +82,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
}, [
stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate,
autoUpdateEnabled, setAutoUpdateEnabled,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
]);