refactor(scheduler): drive scheduled-action metadata from a shared registry (#1428)

Scheduled-operation action metadata was duplicated across the backend route
validator, the DatabaseService action union, the desktop action picker, the
Timeline lanes, and the mobile labels/tones. Adding or renaming one action meant
editing all of them.

Introduce one registry per package as the single source within that package:

- backend/src/services/scheduledActionRegistry.ts owns the action list and
  target-type validation; routes/scheduledTasks.ts and DatabaseService import
  from it (BackendScheduledAction type, VALID_ACTIONS, validateActionTarget).
- frontend/src/lib/scheduledActions.ts owns the UI metadata (labels, short
  labels, categories, tones, target/node/stack/service flags, helper text) and
  drives the create-flow picker, the All Tasks label, the Timeline lanes, and
  the mobile schedule view.

Timeline lanes now group by semantic category (Lifecycle, Updates, Security,
Maintenance, Backups) sourced from the registry. The update-fleet UI alias is
made explicit via a backendAction field. Backend validation stays authoritative;
parity tests on each side keep the action sets in lockstep.
This commit is contained in:
Anso
2026-06-24 20:09:13 -04:00
committed by GitHub
parent 330f9f1acd
commit 0af7ad1df2
11 changed files with 551 additions and 123 deletions
@@ -15,34 +15,12 @@ import { apiFetch, fetchForNode } from '@/lib/api';
import { Combobox } from '@/components/ui/combobox';
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
import { getCronDescription, formatTimestamp } from '@/lib/scheduling';
const UPDATE_FLEET_ACTION = 'update-fleet' as const;
const ACTION_OPTIONS: Array<{
value: string;
label: string;
targetType: 'stack' | 'fleet' | 'system';
backendAction?: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan';
}> = [
{ value: 'restart', label: 'Restart Stack', targetType: 'stack' },
{ value: 'update', label: 'Auto-update Stack', targetType: 'stack' },
{ value: UPDATE_FLEET_ACTION, label: 'Auto-update All Stacks', targetType: 'fleet', backendAction: 'update' },
{ value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' },
{ value: 'prune', label: 'System Prune', targetType: 'system' },
{ value: 'scan', label: 'Vulnerability Scan', targetType: 'system' },
{ value: 'auto_backup', label: 'Backup Stack Files', targetType: 'stack' },
{ value: 'auto_stop', label: 'Stop Stack (keep containers)', targetType: 'stack' },
{ value: 'auto_down', label: 'Take Stack Down (remove containers)', targetType: 'stack' },
{ value: 'auto_start', label: 'Start Stack', targetType: 'stack' },
];
const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: string; bg: string; actions: ScheduledTask['action'][] }[] = [
{ key: 'restart', label: 'Restart', color: 'var(--brand)', bg: 'oklch(from var(--brand) l c h / 0.18)', actions: ['restart'] },
{ key: 'update', label: 'Update', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)', actions: ['update'] },
{ key: 'scan', label: 'Scan', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)', actions: ['scan'] },
{ key: 'prune', label: 'Prune', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)', actions: ['prune', 'snapshot'] },
{ key: 'auto_stop', label: 'Lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)', actions: ['auto_stop', 'auto_down', 'auto_start', 'auto_backup'] },
];
import {
SCHEDULED_ACTIONS,
SCHEDULED_ACTION_CATEGORIES,
getActionById,
resolveTaskAction,
} from '@/lib/scheduledActions';
const TIMELINE_WINDOW_HOURS = 24;
const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000;
@@ -209,7 +187,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : '');
setEditingTask(null);
setFormName('');
setFormAction(ACTION_OPTIONS[0]?.value ?? 'restart');
setFormAction(SCHEDULED_ACTIONS[0]?.id ?? 'restart');
setFormTargetId(prefillData?.stackName ?? '');
setFormNodeId(nodeId);
setFormCron('0 3 * * *');
@@ -225,7 +203,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
const openEdit = (task: ScheduledTask) => {
setEditingTask(task);
setFormName(task.name);
setFormAction(task.action === 'update' && task.target_type === 'fleet' ? UPDATE_FLEET_ACTION : task.action);
setFormAction(resolveTaskAction(task)?.id ?? task.action);
setFormTargetId(task.target_id || '');
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
setFormCron(task.cron_expression);
@@ -242,23 +220,22 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
};
const handleSave = async () => {
const actionOption = ACTION_OPTIONS.find(a => a.value === formAction);
if (!actionOption) return;
const actionDef = getActionById(formAction);
if (!actionDef) return;
const body: Record<string, unknown> = {
name: formName,
target_type: actionOption.targetType,
action: actionOption.backendAction ?? formAction,
target_type: actionDef.targetType,
action: actionDef.backendAction,
cron_expression: formCron,
enabled: formEnabled,
delete_after_run: formDeleteAfterRun,
};
if (actionOption.targetType === 'stack') {
if (actionDef.requiresStack) {
body.target_id = formTargetId;
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
}
if (formAction === 'scan' || formAction === UPDATE_FLEET_ACTION) {
if (actionDef.requiresNode) {
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
}
if (formAction === 'prune' && formPruneTargets.length > 0) {
@@ -362,14 +339,13 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
}
};
const targetType = ACTION_OPTIONS.find(a => a.value === formAction)?.targetType;
const currentAction = getActionById(formAction);
const cronDescription = getCronDescription(formCron);
const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]);
const isSaveDisabled =
saving || !formName || !formCron
|| (targetType === 'stack' && (!formTargetId || !formNodeId))
|| (formAction === 'scan' && !formNodeId)
|| (formAction === UPDATE_FLEET_ACTION && !formNodeId)
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|| (formAction === 'prune' && formPruneTargets.length === 0);
const windowEnd = now + TIMELINE_WINDOW_MS;
@@ -482,8 +458,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
) : (
<div className="relative">
<div className="space-y-1.5">
{TIMELINE_LANES.map(lane => {
const lanePills = timelinePills.filter(p => lane.actions.includes(p.task.action));
{SCHEDULED_ACTION_CATEGORIES.map(lane => {
const lanePills = timelinePills.filter(p => resolveTaskAction(p.task)?.category === lane.key);
return (
<div key={lane.key} className="grid grid-cols-[80px_1fr] items-center gap-3">
<div className="flex items-center gap-2">
@@ -599,10 +575,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
<TableCell className="font-medium">{task.name}</TableCell>
<TableCell>
<Badge variant="outline">
{(task.action === 'update' && task.target_type === 'fleet'
? ACTION_OPTIONS.find(a => a.value === UPDATE_FLEET_ACTION)
: ACTION_OPTIONS.find(a => a.value === task.action)
)?.label || task.action}
{resolveTaskAction(task)?.label || task.action}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
@@ -691,14 +664,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
<div className="space-y-2">
<Label>Action</Label>
<Combobox
options={ACTION_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
options={SCHEDULED_ACTIONS.map(o => ({ value: o.id, label: o.label }))}
value={formAction}
onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}
placeholder="Select action..."
/>
</div>
{targetType === 'stack' && (
{currentAction?.requiresStack && (
<>
<div className="space-y-2">
<Label>Node</Label>
@@ -719,7 +692,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
disabled={!formNodeId}
/>
</div>
{formAction === 'restart' && formTargetId && availableServices.length > 0 && (
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 0 && (
<div className="space-y-2">
<Label>Services <span className="text-xs text-muted-foreground">(leave empty for all)</span></Label>
<div className="grid grid-cols-2 gap-2">
@@ -742,7 +715,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
</>
)}
{formAction === UPDATE_FLEET_ACTION && (
{currentAction?.requiresNode && !currentAction.requiresStack && (
<div className="space-y-2">
<Label>Node</Label>
<Combobox
@@ -751,20 +724,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
onValueChange={setFormNodeId}
placeholder="Select node..."
/>
<p className="text-xs text-muted-foreground">Every stack on the selected node will be checked and updated when new images are available.</p>
</div>
)}
{formAction === 'scan' && (
<div className="space-y-2">
<Label>Node</Label>
<Combobox
options={nodeOptions}
value={formNodeId}
onValueChange={setFormNodeId}
placeholder="Select node..."
/>
<p className="text-xs text-muted-foreground">Every image on the selected node will be scanned.</p>
{currentAction.helperText && (
<p className="text-xs text-muted-foreground">{currentAction.helperText}</p>
)}
</div>
)}
@@ -16,6 +16,7 @@ vi.mock('@/components/ui/toast-store', () => ({
}));
import { apiFetch, fetchForNode } from '@/lib/api';
import { SCHEDULED_ACTIONS } from '@/lib/scheduledActions';
import ScheduledOperationsView from '../ScheduledOperationsView';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
@@ -169,4 +170,120 @@ describe('ScheduledOperationsView', () => {
expect(postCall![1].localOnly).toBe(true);
});
});
it('renders the five registry category lanes in the timeline view', async () => {
render(<ScheduledOperationsView />);
// Timeline is the default view; the lane track always renders.
for (const lane of ['Lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
expect(await screen.findByText(lane)).toBeInTheDocument();
}
});
it('offers every registry action in the create picker', async () => {
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
await userEvent.click(screen.getAllByRole('combobox')[0]);
for (const action of SCHEDULED_ACTIONS) {
expect(await screen.findByRole('button', { name: action.label })).toBeInTheDocument();
}
});
it('shows the correct conditional fields per action', async () => {
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
// The Combobox toggles selection, so each step selects an action that
// differs from the current one (the modal opens on the first action,
// Restart Stack).
const selectAction = async (label: string) => {
await userEvent.click(screen.getAllByRole('combobox')[0]);
await userEvent.click(await screen.findByRole('button', { name: label }));
};
// Default stack action (Restart Stack): Node + Stack, no Prune Targets.
expect(await screen.findByText('Stack')).toBeInTheDocument();
expect(screen.getByText('Node')).toBeInTheDocument();
expect(screen.queryByText('Prune Targets')).not.toBeInTheDocument();
// Node-only action: Node shown, Stack hidden.
await selectAction('Auto-update All Stacks');
expect(screen.getByText('Node')).toBeInTheDocument();
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
// Fleet snapshot: no Node, no Stack.
await selectAction('Fleet Snapshot');
expect(screen.queryByText('Node')).not.toBeInTheDocument();
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
// Prune: Prune Targets shown, no Node.
await selectAction('System Prune');
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
expect(screen.queryByText('Node')).not.toBeInTheDocument();
});
it('emits node_id and target_id for a stack update save', async () => {
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'stack-update');
// Switch from the default restart to the stack update action.
await userEvent.click(screen.getAllByRole('combobox')[0]);
await userEvent.click(await screen.findByRole('button', { name: 'Auto-update Stack' }));
// Node selector, then the stack selector that loads once a node is chosen.
await userEvent.click(screen.getAllByRole('combobox')[1]);
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
await userEvent.click(screen.getAllByRole('combobox')[2]);
await userEvent.click(await screen.findByRole('button', { name: 'web' }));
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
const postCall = mockedFetch.mock.calls.find(
([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST',
);
expect(postCall).toBeTruthy();
const body = JSON.parse(postCall![1].body);
expect(body).toMatchObject({
name: 'stack-update',
target_type: 'stack',
action: 'update',
target_id: 'web',
node_id: 1,
});
});
});
it('maps the update-fleet alias to action=update, target_type=fleet on save', async () => {
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'fleet-update');
await userEvent.click(screen.getAllByRole('combobox')[0]);
await userEvent.click(await screen.findByRole('button', { name: 'Auto-update All Stacks' }));
// Node selector is the second combobox once the node-only field renders.
await userEvent.click(screen.getAllByRole('combobox')[1]);
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
const postCall = mockedFetch.mock.calls.find(
([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST',
);
expect(postCall).toBeTruthy();
const body = JSON.parse(postCall![1].body);
expect(body).toMatchObject({
name: 'fleet-update',
target_type: 'fleet',
action: 'update',
node_id: 1,
});
});
});
});
@@ -2,37 +2,20 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import type { ScheduledTask } from '@/types/scheduling';
import { Masthead, SectionHead, StateDot } from './mobile-ui';
import { getActionById } from '@/lib/scheduledActions';
import { Masthead, SectionHead, StateDot, type Tone } from './mobile-ui';
interface MobileSchedulesProps {
headerActions: ReactNode;
}
type Tone = 'success' | 'warning' | 'destructive' | 'brand';
function actionTone(action: ScheduledTask['action']): Tone {
return getActionById(action)?.tone ?? 'brand';
}
const ACTION_TONE: Record<ScheduledTask['action'], Tone> = {
restart: 'brand',
update: 'success',
scan: 'success',
prune: 'warning',
snapshot: 'warning',
auto_backup: 'brand',
auto_stop: 'warning',
auto_down: 'destructive',
auto_start: 'success',
};
const ACTION_LABEL: Record<ScheduledTask['action'], string> = {
restart: 'restart',
update: 'update',
scan: 'scan',
prune: 'prune',
snapshot: 'snapshot',
auto_backup: 'backup',
auto_stop: 'stop',
auto_down: 'down',
auto_start: 'start',
};
function actionShortLabel(action: ScheduledTask['action']): string {
return getActionById(action)?.shortLabel ?? action;
}
function hhmm(ts: number): string {
const d = new Date(ts);
@@ -130,7 +113,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
state={next ? hhmm(next.runAt) : '--:--'}
stateTone="brand"
live={false}
meta={next ? `${relative(next.runAt, now)} · ${ACTION_LABEL[next.task.action]} ${targetLabel(next.task)}` : 'nothing scheduled'}
meta={next ? `${relative(next.runAt, now)} · ${actionShortLabel(next.task.action)} ${targetLabel(next.task)}` : 'nothing scheduled'}
right={headerActions}
/>
@@ -147,7 +130,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
upcoming.map((run, i) => {
const prevDay = i > 0 ? dayLabel(upcoming[i - 1].runAt, now) : null;
const day = dayLabel(run.runAt, now);
const tone = ACTION_TONE[run.task.action];
const tone = actionTone(run.task.action);
return (
<div key={`${run.task.id}-${run.runAt}`}>
{day !== prevDay ? <SectionHead>{day}</SectionHead> : null}
@@ -155,7 +138,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
<span className="w-[46px] shrink-0 font-mono tabular-nums text-[13px] text-stat-value">{hhmm(run.runAt)}</span>
<StateDot tone={tone} size={7} glow />
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-stat-subtitle">
<span className="text-stat-value">{ACTION_LABEL[run.task.action]}</span>{` ${targetLabel(run.task)}`}
<span className="text-stat-value">{actionShortLabel(run.task.action)}</span>{` ${targetLabel(run.task)}`}
</span>
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{relative(run.runAt, now)}</span>
</div>
@@ -0,0 +1,67 @@
/**
* Confirms the mobile schedule view renders action short labels from the shared
* registry rather than a local map, so a registry change flows through to mobile.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { ScheduledTask } from '@/types/scheduling';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
import { apiFetch } from '@/lib/api';
import { MobileSchedules } from '../MobileSchedules';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function jsonResponse(body: unknown): Response {
return { ok: true, status: 200, json: async () => body } as unknown as Response;
}
function makeTask(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
const soon = Date.now() + 3_600_000;
return {
id: 1,
name: 'task',
target_type: 'stack',
target_id: 'web',
node_id: 1,
action: 'auto_backup',
cron_expression: '0 3 * * *',
enabled: 1,
created_by: 'admin',
created_at: 0,
updated_at: 0,
last_run_at: null,
next_run_at: soon,
last_status: null,
last_error: null,
prune_targets: null,
target_services: null,
prune_label_filter: null,
next_runs: [soon],
...overrides,
};
}
beforeEach(() => {
mockedFetch.mockReset();
});
afterEach(() => vi.clearAllMocks());
describe('MobileSchedules', () => {
it('renders registry short labels for upcoming runs', async () => {
mockedFetch.mockResolvedValue(jsonResponse([
makeTask({ id: 1, action: 'auto_backup' }),
makeTask({ id: 2, action: 'auto_down', next_runs: [Date.now() + 7_200_000] }),
]));
const { container } = render(<MobileSchedules headerActions={null} />);
expect(await screen.findByText('backup')).toBeInTheDocument();
expect(await screen.findByText('down')).toBeInTheDocument();
// auto_down carries the destructive tone in the registry; its StateDot
// renders with the destructive class only if the tone is wired through.
expect(container.querySelector('.bg-destructive')).toBeTruthy();
});
});