feat(auto-update): per-stack auto-update enable/disable toggle (#771)

* feat(auto-update): add per-stack auto-update enable/disable toggle

Paid users (Skipper and Admiral) can now opt individual stacks out of
scheduled auto-updates from the stack context menu without disabling
the global feature.

- Add stack_auto_update_settings table (node_id, stack_name) with
  default enabled=true; four typed DatabaseService accessors with
  parameterized queries.
- Add GET /stacks/auto-update-settings, GET /stacks/:name/auto-update,
  and PUT /stacks/:name/auto-update (requirePaid + requireAdmin).
  PUT broadcasts state-invalidate with action auto-update-settings-changed
  so all open tabs refresh immediately.
- Stack DELETE clears the auto-update setting row alongside stack_update_status.
- autoUpdateRouter /execute skips disabled stacks before any registry
  call; skip is recorded in the results array. Manual Update actions
  are not affected.
- Add Auto-update: Enabled/Disabled toggle in the stack inspect group
  (paid tiers only, hidden for Community, consistent with Auto-Heal).
  Toggle uses optimistic update with revert-on-error toast.
- AutoUpdateReadinessView shows an Auto: Off pill and disables the
  Apply now button for stacks with auto-updates off. Detection still
  runs so the readiness card remains visible.
- Add 21 backend Vitest tests covering DB round-trips, endpoint auth
  and tier gates, execute skip for both wildcard and named targets.
  Add 3 frontend hook tests for toggle visibility and callback behavior.

* docs(auto-update): document per-stack auto-update control

Add a Per-stack control section to the auto-update readiness page
explaining how to disable and re-enable auto-updates for individual
stacks, what disabling means (scheduled apply skipped; detection still
runs; manual update unaffected), and a troubleshooting entry for
scheduled runs not applying to a specific stack.
This commit is contained in:
Anso
2026-04-25 10:50:21 -04:00
committed by GitHub
parent 58df1a50b3
commit af9cb0aa63
10 changed files with 501 additions and 10 deletions
+42 -3
View File
@@ -347,6 +347,8 @@ export default function EditorLayout() {
// Image update checker state
const [stackUpdates, setStackUpdates] = useState<Record<string, boolean>>({});
const [autoUpdateSettings, setAutoUpdateSettings] = useState<Record<string, boolean>>({});
const isAdmiral = license?.variant === 'admiral';
// Notifications & Settings state
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
@@ -667,7 +669,11 @@ export default function EditorLayout() {
// sidebar, etc.) can refetch on the same trigger without prop
// drilling. Refresh stack statuses on this layer too.
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg }));
scheduleStateInvalidateRefresh();
if (msg.action === 'auto-update-settings-changed') {
fetchAutoUpdateSettings();
} else {
scheduleStateInvalidateRefresh();
}
}
} catch (e) {
console.error('[WS notifications] parse error', e);
@@ -811,6 +817,7 @@ export default function EditorLayout() {
refreshStacks();
fetchImageUpdates();
fetchAutoUpdateSettings();
refreshGitSourcePending();
// Poll for image update results every 5 minutes so background checks are picked up
@@ -875,6 +882,20 @@ export default function EditorLayout() {
}
};
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 markAllRead = async () => {
try {
const localNode = nodesRef.current.find(n => n.type === 'local');
@@ -1899,11 +1920,13 @@ export default function EditorLayout() {
hasPort: Boolean(stackPorts[file]),
isBusy: isStackBusy(file),
isPaid,
isAdmiral,
canDelete: can('stack:delete', 'stack', stackName),
isPinned: isPinned(file),
labels,
assignedLabelIds: (stackLabelMap[file] ?? []).map(l => l.id),
menuVisibility: getStackMenuVisibility(file),
autoUpdateEnabled: autoUpdateSettings[stackName] ?? true,
openAlertSheet: () => openAlertSheet(file),
openAutoHeal: () => setAutoHealStackName(file),
checkUpdates: () => checkUpdatesForStack(),
@@ -1915,6 +1938,22 @@ export default function EditorLayout() {
remove: () => { setStackToDelete(stackName); setDeleteDialogOpen(true); },
pin: () => pin(file),
unpin: () => unpin(file),
setAutoUpdateEnabled: async (enabled: boolean) => {
setAutoUpdateSettings(prev => ({ ...prev, [stackName]: enabled }));
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/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) {
setAutoUpdateSettings(prev => ({ ...prev, [stackName]: !enabled }));
toast.error((err as Error)?.message || 'Failed to update auto-update setting.');
}
},
toggleLabel: async (labelId: number) => {
const currentIds = (stackLabelMap[file] ?? []).map(l => l.id);
const assigned = currentIds.includes(labelId);
@@ -1968,8 +2007,8 @@ export default function EditorLayout() {
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
stackStatuses, stackPorts, isPaid, isPinned, labels, stackLabelMap,
pin, unpin,
stackStatuses, stackPorts, isPaid, isAdmiral, isPinned, labels, stackLabelMap,
autoUpdateSettings, pin, unpin,
]);
const createStackSlot = can('stack:create') ? (