mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +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:
@@ -88,8 +88,7 @@ function buildConfig(summary: SidebarActivitySummary): RenderConfig {
|
||||
iconClass: 'text-warning',
|
||||
primary: (
|
||||
<span className="font-mono text-[11px] truncate">
|
||||
<span className="text-foreground">Auto-update </span>
|
||||
<span className="text-brand">{summary.enabledCount}/{summary.totalCount}</span>
|
||||
<span className="text-foreground">Auto-update</span>
|
||||
<span className="text-muted-foreground"> · next run {nextLabel}</span>
|
||||
</span>
|
||||
),
|
||||
|
||||
@@ -54,11 +54,11 @@ describe('SidebarActivityTicker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders automation state with counts, next-run time, and routes click to open-auto-updates', () => {
|
||||
it('renders automation state with next-run time, and routes click to open-auto-updates', () => {
|
||||
const nextRun = Math.floor(Date.now() / 1000) + 600;
|
||||
const { onAction } = renderWith({ kind: 'automation', enabledCount: 3, totalCount: 8, nextRunAt: nextRun });
|
||||
const { onAction } = renderWith({ kind: 'automation', nextRunAt: nextRun });
|
||||
expect(screen.getByText(/Auto-update/)).toBeInTheDocument();
|
||||
expect(screen.getByText('3/8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/next run/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onAction).toHaveBeenCalledWith({ kind: 'open-auto-updates' });
|
||||
|
||||
@@ -56,14 +56,14 @@ describe('useNextAutoUpdateRun', () => {
|
||||
expect(result.current).toBe(1_700);
|
||||
});
|
||||
|
||||
it('debounces rapid invalidations into a single refetch', async () => {
|
||||
it('debounces rapid scheduled-tasks invalidations into a single refetch', async () => {
|
||||
renderHook(() => useNextAutoUpdateRun());
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks' });
|
||||
});
|
||||
// Debounce window not yet elapsed: still only the mount call.
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -76,8 +76,8 @@ describe('useNextAutoUpdateRun', () => {
|
||||
renderHook(() => useNextAutoUpdateRun());
|
||||
apiFetchMock.mockClear();
|
||||
act(() => {
|
||||
fireInvalidate({ action: 'something-else' });
|
||||
fireInvalidate({ scope: 'unrelated' });
|
||||
fireInvalidate({ scope: 'stack' });
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(0);
|
||||
@@ -98,7 +98,7 @@ describe('useNextAutoUpdateRun', () => {
|
||||
apiFetchMock.mockClear();
|
||||
unmount();
|
||||
await act(async () => {
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ scope: 'scheduled-tasks' });
|
||||
vi.advanceTimersByTime(120_000);
|
||||
});
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { __testing, countEnabledAutoUpdates } from '../useSidebarActivitySummary';
|
||||
import { __testing } from '../useSidebarActivitySummary';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
@@ -29,8 +29,6 @@ function inputs(overrides: Partial<Parameters<typeof deriveSummary>[0]> = {}) {
|
||||
tickerConnected: true,
|
||||
panelState: IDLE_PANEL,
|
||||
panelStartedAt: null,
|
||||
autoUpdateEnabledCount: 0,
|
||||
totalStackCount: 0,
|
||||
nextAutoUpdateRunAt: null,
|
||||
...overrides,
|
||||
};
|
||||
@@ -69,8 +67,6 @@ describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
const recent = notif({ id: 10, timestamp: NOW_SECS - 5 });
|
||||
const r = deriveSummary(inputs({
|
||||
notifications: [failure, recent],
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('failure');
|
||||
@@ -84,16 +80,12 @@ describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
expect(r.kind).not.toBe('failure');
|
||||
});
|
||||
|
||||
it('returns automation when auto-update is enabled and no recent event exists', () => {
|
||||
it('returns automation when a next-run is known and no recent event exists', () => {
|
||||
const r = deriveSummary(inputs({
|
||||
autoUpdateEnabledCount: 2,
|
||||
totalStackCount: 4,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('automation');
|
||||
if (r.kind === 'automation') {
|
||||
expect(r.enabledCount).toBe(2);
|
||||
expect(r.totalCount).toBe(4);
|
||||
expect(r.nextRunAt).toBe(NOW_SECS + 3600);
|
||||
}
|
||||
});
|
||||
@@ -102,18 +94,14 @@ describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
const recent = notif({ id: 7, timestamp: NOW_SECS - 5 });
|
||||
const r = deriveSummary(inputs({
|
||||
notifications: [recent],
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('recent-event');
|
||||
if (r.kind === 'recent-event') expect(r.notif.id).toBe(7);
|
||||
});
|
||||
|
||||
it('drops automation when no next-run is known, even with auto-update settings present', () => {
|
||||
it('drops automation when no next-run is known', () => {
|
||||
const r = deriveSummary(inputs({
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: null,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
@@ -160,25 +148,3 @@ describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countEnabledAutoUpdates', () => {
|
||||
it('counts a stack with no explicit row as enabled (backend default-true contract)', () => {
|
||||
expect(countEnabledAutoUpdates(['web', 'api'], {})).toBe(2);
|
||||
});
|
||||
|
||||
it('respects an explicit false', () => {
|
||||
expect(countEnabledAutoUpdates(['web', 'api', 'db'], { api: false })).toBe(2);
|
||||
});
|
||||
|
||||
it('respects an explicit true', () => {
|
||||
expect(countEnabledAutoUpdates(['web'], { web: true })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty file list', () => {
|
||||
expect(countEnabledAutoUpdates([], { web: true })).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores settings rows that do not correspond to known files', () => {
|
||||
expect(countEnabledAutoUpdates(['web'], { ghost: false, web: true })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface StackMenuCtx {
|
||||
labels: Label[];
|
||||
assignedLabelIds: number[];
|
||||
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean };
|
||||
autoUpdateEnabled: boolean;
|
||||
openAlertSheet: () => void;
|
||||
openAutoHeal: () => void;
|
||||
checkUpdates: () => void;
|
||||
@@ -49,7 +48,6 @@ export interface StackMenuCtx {
|
||||
toggleLabel: (labelId: number) => void;
|
||||
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
|
||||
openLabelManager: () => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean) => void;
|
||||
openScheduleTask: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ export function useNextAutoUpdateRun(): number | null {
|
||||
run();
|
||||
|
||||
const onInvalidate = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ action?: string; scope?: string }>).detail;
|
||||
if (detail?.action !== 'auto-update-settings-changed' && detail?.scope !== 'scheduled-tasks') return;
|
||||
const detail = (e as CustomEvent<{ scope?: string }>).detail;
|
||||
if (detail?.scope !== 'scheduled-tasks') return;
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = setTimeout(() => { invalidateTimer = null; run(); }, INVALIDATE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const RECENT_WINDOW_SECS = 60 * 60;
|
||||
export type SidebarActivitySummary =
|
||||
| { kind: 'active-op'; stackName: string; action: ActionVerb; startedAt: number }
|
||||
| { kind: 'failure'; notif: NotificationItem }
|
||||
| { kind: 'automation'; enabledCount: number; totalCount: number; nextRunAt: number }
|
||||
| { kind: 'automation'; nextRunAt: number }
|
||||
| { kind: 'recent-event'; notif: NotificationItem }
|
||||
| { kind: 'quiet-live' }
|
||||
| { kind: 'disconnected' };
|
||||
@@ -19,9 +19,6 @@ interface SummaryInputs {
|
||||
tickerConnected: boolean;
|
||||
panelState: DeployPanelState;
|
||||
panelStartedAt: number | null;
|
||||
/** Pre-aggregated by the caller so the memo dep list stays scalar; see EditorLayout. */
|
||||
autoUpdateEnabledCount: number;
|
||||
totalStackCount: number;
|
||||
nextAutoUpdateRunAt: number | null;
|
||||
}
|
||||
|
||||
@@ -54,7 +51,7 @@ function findRecent(notifications: NotificationItem[], nowSecs: number): Notific
|
||||
* 1. active-op: a deploy panel is preparing/streaming
|
||||
* 2. failure: newest unread stack-scoped error in the last 24h
|
||||
* 3. recent-event: newest non-error stack notification in the last hour
|
||||
* 4. automation: auto-update is enabled and a next run is scheduled
|
||||
* 4. automation: a next auto-update run is scheduled
|
||||
* 5. disconnected: notification WebSocket is down
|
||||
* 6. quiet-live: nothing else to surface
|
||||
*
|
||||
@@ -64,7 +61,7 @@ function findRecent(notifications: NotificationItem[], nowSecs: number): Notific
|
||||
* follow the same order; if you change the cascade, update both.
|
||||
*/
|
||||
function deriveSummary(inputs: SummaryInputs, nowSecs: number): SidebarActivitySummary {
|
||||
const { panelState, panelStartedAt, notifications, autoUpdateEnabledCount, totalStackCount, nextAutoUpdateRunAt, tickerConnected } = inputs;
|
||||
const { panelState, panelStartedAt, notifications, nextAutoUpdateRunAt, tickerConnected } = inputs;
|
||||
|
||||
if (panelState.isOpen && (panelState.status === 'preparing' || panelState.status === 'streaming') && panelStartedAt !== null) {
|
||||
return { kind: 'active-op', stackName: panelState.stackName, action: panelState.action, startedAt: panelStartedAt };
|
||||
@@ -77,8 +74,8 @@ function deriveSummary(inputs: SummaryInputs, nowSecs: number): SidebarActivityS
|
||||
}
|
||||
|
||||
const recent = findRecent(notifications, nowSecs);
|
||||
if (!recent && autoUpdateEnabledCount > 0 && nextAutoUpdateRunAt !== null) {
|
||||
return { kind: 'automation', enabledCount: autoUpdateEnabledCount, totalCount: totalStackCount, nextRunAt: nextAutoUpdateRunAt };
|
||||
if (!recent && nextAutoUpdateRunAt !== null) {
|
||||
return { kind: 'automation', nextRunAt: nextAutoUpdateRunAt };
|
||||
}
|
||||
|
||||
if (recent) {
|
||||
@@ -111,23 +108,10 @@ export function useSidebarActivitySummary(inputs: SummaryInputs): SidebarActivit
|
||||
inputs.panelState.action,
|
||||
inputs.panelState.status,
|
||||
inputs.panelStartedAt,
|
||||
inputs.autoUpdateEnabledCount,
|
||||
inputs.totalStackCount,
|
||||
inputs.nextAutoUpdateRunAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count stacks with auto-update enabled. Backend defaults missing rows to
|
||||
* enabled (DatabaseService.getStackAutoUpdateSettingsForNode); callers must
|
||||
* NOT treat absence as disabled.
|
||||
*/
|
||||
export function countEnabledAutoUpdates(files: string[], settings: Record<string, boolean>): number {
|
||||
let n = 0;
|
||||
for (const f of files) if (settings[f] ?? true) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Exported for unit tests so we don't need to spin up a renderer to validate cascade logic.
|
||||
export const __testing = { deriveSummary };
|
||||
|
||||
Reference in New Issue
Block a user