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
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, Shield, AlertTriangle, ShieldAlert, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react';
import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch, fetchForNode } from '@/lib/api';
import { PaidGate } from '@/components/PaidGate';
@@ -46,6 +46,7 @@ interface StackCard {
previewLoaded: boolean;
scheduledTask: ScheduledTask | null;
applying: boolean;
autoUpdateEnabled: boolean;
}
interface NodeGroup {
@@ -142,7 +143,7 @@ function StackReadinessCard({
card: StackCard;
onApply: (stack: string, nodeId: number) => void;
}) {
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying } = card;
const { stack, nodeId, preview, previewLoaded, scheduledTask, applying, autoUpdateEnabled } = card;
const loading = !previewLoaded;
const failed = previewLoaded && preview === null;
const blocked = preview?.summary.blocked ?? false;
@@ -161,7 +162,15 @@ function StackReadinessCard({
{stack}
</span>
</div>
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
<div className="flex items-center gap-2 shrink-0">
{!autoUpdateEnabled && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-card-border bg-muted/30 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
<CircleSlash className="h-3 w-3" strokeWidth={1.5} />
Auto: Off
</span>
)}
{previewLoaded && preview && <RiskBadge bump={bump} blocked={blocked} />}
</div>
</div>
{loading ? (
@@ -227,8 +236,12 @@ function StackReadinessCard({
<Button
size="sm"
onClick={() => onApply(stack, nodeId)}
disabled={blocked || applying}
title={blocked ? (blockedReason ?? undefined) : undefined}
disabled={blocked || applying || !autoUpdateEnabled}
title={
!autoUpdateEnabled
? 'Auto-updates are disabled for this stack. Update it from its actions menu.'
: (blocked ? (blockedReason ?? undefined) : undefined)
}
className="gap-1.5"
>
<Play className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
@@ -382,6 +395,8 @@ function AutoUpdateReadinessContent() {
apiFetch('/image-updates/fleet', { localOnly: true }),
apiFetch('/scheduled-tasks?action=update', { localOnly: true }),
]);
// Auto-update settings are per-node; fetch lazily after we know which nodes have updates.
// Collected into a map keyed by nodeId once we know the fleet topology.
if (token !== loadTokenRef.current) return;
if (!statusRes.ok) {
@@ -404,6 +419,20 @@ function AutoUpdateReadinessContent() {
}
}
// Fetch auto-update settings for all nodes that have pending updates.
const nodeIdsWithUpdates = [...new Set(
Object.keys(fleetStatus).map(Number).filter(id => Object.values(fleetStatus[String(id)]).some(Boolean))
)];
const autoUpdateByNode = new Map<number, Record<string, boolean>>();
await Promise.all(nodeIdsWithUpdates.map(async (nodeId) => {
try {
const res = await fetchForNode('/stacks/auto-update-settings', nodeId);
if (res.ok) autoUpdateByNode.set(nodeId, await res.json() as Record<string, boolean>);
} catch {
// If the fetch fails, default all stacks on that node to enabled.
}
}));
const flatPairs: { nodeId: number; stack: string }[] = [];
const initialGroups: NodeGroup[] = [];
const currentNodes = nodesRef.current;
@@ -416,6 +445,7 @@ function AutoUpdateReadinessContent() {
.map(([stack]) => stack)
.sort();
if (stacks.length === 0) continue;
const nodeAutoUpdateSettings = autoUpdateByNode.get(nodeId) ?? {};
const cards: StackCard[] = stacks.map(stack => {
flatPairs.push({ nodeId, stack });
return {
@@ -425,6 +455,7 @@ function AutoUpdateReadinessContent() {
previewLoaded: false,
scheduledTask: taskByNodeStack.get(`${nodeId}::${stack}`) ?? null,
applying: false,
autoUpdateEnabled: nodeAutoUpdateSettings[stack] ?? true,
};
});
initialGroups.push({
+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') ? (
@@ -26,11 +26,16 @@ export interface StackMenuCtx {
hasPort: boolean;
isBusy: boolean;
isPaid: boolean;
// isAdmiral: plumbed now so Admiral-specific menu items can be added in a
// follow-up PR without changing this interface. No Admiral-only items ship
// in the current PR; the auto-update toggle is Skipper+, gated on isPaid.
isAdmiral: boolean;
canDelete: boolean;
isPinned: boolean;
labels: Label[];
assignedLabelIds: number[];
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean };
autoUpdateEnabled: boolean;
openAlertSheet: () => void;
openAutoHeal: () => void;
checkUpdates: () => void;
@@ -45,6 +50,7 @@ export interface StackMenuCtx {
toggleLabel: (labelId: number) => void;
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
openLabelManager: () => void;
setAutoUpdateEnabled: (enabled: boolean) => void;
}
export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled';
@@ -10,11 +10,13 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
hasPort: true,
isBusy: false,
isPaid: true,
isAdmiral: false,
canDelete: true,
isPinned: false,
labels: [],
assignedLabelIds: [],
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false },
autoUpdateEnabled: true,
openAlertSheet: vi.fn(),
openAutoHeal: vi.fn(),
checkUpdates: vi.fn(),
@@ -29,6 +31,7 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
toggleLabel: vi.fn(),
createAndAssignLabel: vi.fn(),
openLabelManager: vi.fn(),
setAutoUpdateEnabled: vi.fn(),
...overrides,
};
}
@@ -88,6 +91,28 @@ describe('useStackMenuItems', () => {
expect(ids).toEqual(['deploy', 'update']);
});
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('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('disables every lifecycle item when isBusy', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
isBusy: true,
+9 -1
View File
@@ -3,6 +3,7 @@ import {
Activity,
ArrowUpRight,
BellRing,
CircleSlash,
Download,
Pin,
PinOff,
@@ -20,7 +21,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
menuVisibility,
menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled,
} = ctx;
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
@@ -32,6 +33,12 @@ 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) {
@@ -80,6 +87,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
}, [
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate,
autoUpdateEnabled, setAutoUpdateEnabled,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
]);