Files
sencho/frontend/src/components/sidebar/useSidebarActivitySummary.ts
T
Anso adcd04b01a 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.
2026-05-26 11:08:33 -04:00

118 lines
4.2 KiB
TypeScript

import { useEffect, useMemo, useReducer } from 'react';
import type { ActionVerb, DeployPanelState } from '@/context/DeployFeedbackContext';
import type { NotificationItem } from '@/components/dashboard/types';
const NOW_TICK_MS = 10_000;
const FAILURE_WINDOW_SECS = 24 * 60 * 60;
const RECENT_WINDOW_SECS = 60 * 60;
export type SidebarActivitySummary =
| { kind: 'active-op'; stackName: string; action: ActionVerb; startedAt: number }
| { kind: 'failure'; notif: NotificationItem }
| { kind: 'automation'; nextRunAt: number }
| { kind: 'recent-event'; notif: NotificationItem }
| { kind: 'quiet-live' }
| { kind: 'disconnected' };
interface SummaryInputs {
notifications: NotificationItem[];
tickerConnected: boolean;
panelState: DeployPanelState;
panelStartedAt: number | null;
nextAutoUpdateRunAt: number | null;
}
function findFailure(notifications: NotificationItem[], nowSecs: number): NotificationItem | null {
for (const n of notifications) {
if (n.level !== 'error') continue;
if (n.is_read) continue;
// System-level errors with no stack_name cannot be routed via
// navigateToNotification; let the top-bar NotificationPanel surface them
// instead so the sidebar footer's "view logs" click always lands somewhere.
if (!n.stack_name) continue;
if (nowSecs - n.timestamp > FAILURE_WINDOW_SECS) continue;
return n;
}
return null;
}
function findRecent(notifications: NotificationItem[], nowSecs: number): NotificationItem | null {
for (const n of notifications) {
if (!n.stack_name) continue;
if (n.level === 'error') continue;
if (nowSecs - n.timestamp > RECENT_WINDOW_SECS) continue;
return n;
}
return null;
}
/**
* Priority cascade (first match wins):
* 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: a next auto-update run is scheduled
* 5. disconnected: notification WebSocket is down
* 6. quiet-live: nothing else to surface
*
* Note: recent-event preempts automation because a fresh deploy/restart event
* is more time-relevant than ambient steady-state ("your last action was 30s
* ago" beats "auto-update will run at 02:00"). The PR description and tests
* follow the same order; if you change the cascade, update both.
*/
function deriveSummary(inputs: SummaryInputs, nowSecs: number): SidebarActivitySummary {
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 };
}
// Notifications are pre-sorted newest-first by useNotifications.
const failure = findFailure(notifications, nowSecs);
if (failure) {
return { kind: 'failure', notif: failure };
}
const recent = findRecent(notifications, nowSecs);
if (!recent && nextAutoUpdateRunAt !== null) {
return { kind: 'automation', nextRunAt: nextAutoUpdateRunAt };
}
if (recent) {
return { kind: 'recent-event', notif: recent };
}
if (!tickerConnected) {
return { kind: 'disconnected' };
}
return { kind: 'quiet-live' };
}
export function useSidebarActivitySummary(inputs: SummaryInputs): SidebarActivitySummary {
const [tick, forceTick] = useReducer((x: number) => x + 1, 0);
useEffect(() => {
const id = setInterval(forceTick, NOW_TICK_MS);
return () => clearInterval(id);
}, []);
return useMemo(() => deriveSummary(inputs, Math.floor(Date.now() / 1000)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
tick,
inputs.notifications,
inputs.tickerConnected,
inputs.panelState.isOpen,
inputs.panelState.stackName,
inputs.panelState.action,
inputs.panelState.status,
inputs.panelStartedAt,
inputs.nextAutoUpdateRunAt,
],
);
}
// Exported for unit tests so we don't need to spin up a renderer to validate cascade logic.
export const __testing = { deriveSummary };