mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 21:48:45 +00:00
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:
@@ -46,6 +46,9 @@ interface StackCard {
|
||||
previewLoaded: boolean;
|
||||
scheduledTask: ScheduledTask | null;
|
||||
applying: boolean;
|
||||
// True when at least one enabled action='update' scheduled task covers this
|
||||
// stack on this node (per-stack row or fleet row). Drives the Auto: Off pill
|
||||
// and the Apply now button's disabled state.
|
||||
autoUpdateEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -239,7 +242,7 @@ function StackReadinessCard({
|
||||
disabled={blocked || applying || !autoUpdateEnabled}
|
||||
title={
|
||||
!autoUpdateEnabled
|
||||
? 'Auto-updates are disabled for this stack. Update it from its actions menu.'
|
||||
? 'No schedule covers this stack. Create one in Schedules → Auto-update Stack.'
|
||||
: (blocked ? (blockedReason ?? undefined) : undefined)
|
||||
}
|
||||
className="gap-1.5"
|
||||
@@ -295,7 +298,7 @@ function ReadinessHero({
|
||||
{total > 0 && (
|
||||
<span className="font-mono text-[11px] text-stat-subtitle/90">
|
||||
{ready} of {total} ready to apply automatically{acrossNodes}
|
||||
{total - ready > 0 ? ` · ${total - ready} blocked by major bump` : ''}
|
||||
{total - ready > 0 ? ` · ${total - ready} need a schedule or review` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -395,8 +398,6 @@ 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) {
|
||||
@@ -406,33 +407,33 @@ function AutoUpdateReadinessContent() {
|
||||
setReachableNodeCount(Object.keys(fleetStatus).length);
|
||||
|
||||
const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : [];
|
||||
// A stack is "covered" by an enabled action='update' row when either
|
||||
// a per-stack row targets it or a fleet row targets its node. We pick
|
||||
// the earliest next-run covering task so the readiness card renders
|
||||
// the next-run time accurately for both shapes.
|
||||
const taskByNodeStack = new Map<string, ScheduledTask>();
|
||||
const fleetTaskByNode = new Map<number, ScheduledTask>();
|
||||
for (const t of tasks) {
|
||||
if (t.target_type !== 'stack' || !t.target_id) continue;
|
||||
// Tasks with node_id=null are local-node-scoped.
|
||||
if (!t.enabled) continue;
|
||||
// The fetch URL filters on action=update; this guard makes the
|
||||
// coverage check robust against a future regression there.
|
||||
if (t.action !== 'update') continue;
|
||||
const taskNodeId = t.node_id ?? localNodeId;
|
||||
if (taskNodeId == null) continue;
|
||||
const key = `${taskNodeId}::${t.target_id}`;
|
||||
const existing = taskByNodeStack.get(key);
|
||||
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
||||
taskByNodeStack.set(key, t);
|
||||
if (t.target_type === 'fleet') {
|
||||
const existing = fleetTaskByNode.get(taskNodeId);
|
||||
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
||||
fleetTaskByNode.set(taskNodeId, t);
|
||||
}
|
||||
} else if (t.target_type === 'stack' && t.target_id) {
|
||||
const key = `${taskNodeId}::${t.target_id}`;
|
||||
const existing = taskByNodeStack.get(key);
|
||||
if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) {
|
||||
taskByNodeStack.set(key, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -445,17 +446,24 @@ 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 });
|
||||
const stackTask = taskByNodeStack.get(`${nodeId}::${stack}`) ?? null;
|
||||
const fleetTask = fleetTaskByNode.get(nodeId) ?? null;
|
||||
// Prefer whichever covering task fires next.
|
||||
// Earliest next-run wins; on a tie, the per-stack row beats the
|
||||
// fleet row so the user sees the more specific schedule.
|
||||
const scheduledTask = stackTask && fleetTask
|
||||
? ((stackTask.next_run_at ?? Infinity) <= (fleetTask.next_run_at ?? Infinity) ? stackTask : fleetTask)
|
||||
: (stackTask ?? fleetTask);
|
||||
return {
|
||||
stack,
|
||||
nodeId,
|
||||
preview: null,
|
||||
previewLoaded: false,
|
||||
scheduledTask: taskByNodeStack.get(`${nodeId}::${stack}`) ?? null,
|
||||
scheduledTask,
|
||||
applying: false,
|
||||
autoUpdateEnabled: nodeAutoUpdateSettings[stack] ?? true,
|
||||
autoUpdateEnabled: scheduledTask !== null,
|
||||
};
|
||||
});
|
||||
initialGroups.push({
|
||||
@@ -593,7 +601,15 @@ function AutoUpdateReadinessContent() {
|
||||
const flatCards = useMemo(() => groups.flatMap(g => g.cards), [groups]);
|
||||
const { total, ready } = useMemo(() => {
|
||||
const t = flatCards.length;
|
||||
const r = flatCards.filter(c => c.previewLoaded && c.preview !== null && !c.preview.summary.blocked).length;
|
||||
// "Ready" means a schedule covers the stack, the preview loaded without
|
||||
// error, and no major-bump blocked it. Without a covering schedule the
|
||||
// stack cannot apply automatically regardless of preview state.
|
||||
const r = flatCards.filter(c =>
|
||||
c.autoUpdateEnabled
|
||||
&& c.previewLoaded
|
||||
&& c.preview !== null
|
||||
&& !c.preview.summary.blocked,
|
||||
).length;
|
||||
return { total: t, ready: r };
|
||||
}, [flatCards]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user