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';