mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user