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
@@ -0,0 +1,77 @@
/**
* Locks the backend scheduled-action registry: the action list stays in sync
* with what the route layer validates, and validateActionTarget reproduces the
* exact per-action error messages that are part of the API contract.
*/
import { describe, it, expect } from 'vitest';
import {
VALID_ACTIONS,
BACKEND_SCHEDULED_ACTIONS,
INVALID_ACTION_MESSAGE,
validateActionTarget,
type BackendScheduledAction,
type TargetType,
} from '../services/scheduledActionRegistry';
const EXPECTED_ACTIONS: BackendScheduledAction[] = [
'restart', 'snapshot', 'prune', 'update', 'scan',
'auto_backup', 'auto_stop', 'auto_down', 'auto_start',
];
const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system'];
describe('scheduledActionRegistry', () => {
it('exposes exactly the known backend actions, in order', () => {
expect([...VALID_ACTIONS]).toEqual(EXPECTED_ACTIONS);
});
it('every valid action has a registry entry and vice versa', () => {
const entryIds = BACKEND_SCHEDULED_ACTIONS.map(a => a.id);
expect([...entryIds].sort()).toEqual([...VALID_ACTIONS].sort());
});
it('derives the invalid-action message from the action list', () => {
expect(INVALID_ACTION_MESSAGE).toBe(
'Invalid action. Must be restart, snapshot, prune, update, scan, auto_backup, auto_stop, auto_down, or auto_start.',
);
});
describe('validateActionTarget', () => {
const validPairs: Record<BackendScheduledAction, TargetType[]> = {
restart: ['stack'],
snapshot: ['fleet'],
prune: ['system'],
update: ['stack', 'fleet'],
scan: ['system'],
auto_backup: ['stack'],
auto_stop: ['stack'],
auto_down: ['stack'],
auto_start: ['stack'],
};
const mismatchMessage: Record<BackendScheduledAction, string> = {
restart: 'Restart action requires target_type "stack".',
snapshot: 'Snapshot action requires target_type "fleet".',
prune: 'Prune action requires target_type "system".',
update: 'Update action requires target_type "stack" or "fleet".',
scan: 'Scan action requires target_type "system".',
auto_backup: 'auto_backup action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack".',
auto_down: 'auto_down action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack".',
};
for (const action of EXPECTED_ACTIONS) {
it(`accepts valid and rejects invalid target types for ${action}`, () => {
for (const targetType of ALL_TARGET_TYPES) {
const result = validateActionTarget(action, targetType);
if (validPairs[action].includes(targetType)) {
expect(result).toBeNull();
} else {
expect(result).toBe(mismatchMessage[action]);
}
}
});
}
});
});
+11 -27
View File
@@ -1,6 +1,14 @@
import { Router, type Request, type Response } from 'express';
import { CronExpressionParser } from 'cron-parser';
import { DatabaseService, type ScheduledTask } from '../services/DatabaseService';
import {
VALID_TARGET_TYPES,
VALID_ACTIONS,
INVALID_ACTION_MESSAGE,
validateActionTarget,
type TargetType,
type BackendScheduledAction,
} from '../services/scheduledActionRegistry';
import { SchedulerService } from '../services/SchedulerService';
import { NotificationService } from '../services/NotificationService';
import { requireAdmin } from '../middleware/tierGates';
@@ -24,33 +32,9 @@ function broadcastScheduledTasksChanged(): void {
}
}
const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start'] as const;
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
type TargetType = typeof VALID_TARGET_TYPES[number];
type ScheduledAction = typeof VALID_ACTIONS[number];
const STACK_ONLY_ACTIONS = new Set<ScheduledAction>(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']);
/**
* Validate that the target_type is compatible with the action. Each action
* has exactly one allowed target_type; the helper returns an error message
* on mismatch and null otherwise.
*/
function validateActionTarget(action: ScheduledAction, targetType: TargetType): string | null {
if (action === 'restart' && targetType !== 'stack') return 'Restart action requires target_type "stack".';
if (action === 'update' && targetType !== 'stack' && targetType !== 'fleet') return 'Update action requires target_type "stack" or "fleet".';
if (action === 'snapshot' && targetType !== 'fleet') return 'Snapshot action requires target_type "fleet".';
if (action === 'prune' && targetType !== 'system') return 'Prune action requires target_type "system".';
if (action === 'scan' && targetType !== 'system') return 'Scan action requires target_type "system".';
if (STACK_ONLY_ACTIONS.has(action) && targetType !== 'stack') {
return `${action} action requires target_type "stack".`;
}
return null;
}
function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
if (targetType !== 'stack') return null;
@@ -84,7 +68,7 @@ function validateScanNode(nodeId: unknown): string | null {
/** Shared validation for prune_targets, target_services, prune_label_filter. Returns an error string or null. */
function validateOptionalFields(
action: ScheduledAction,
action: BackendScheduledAction,
targetType: TargetType,
prune_targets: unknown,
target_services: unknown,
@@ -163,7 +147,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
}
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, scan, auto_backup, auto_stop, auto_down, or auto_start.' }); return;
res.status(400).json({ error: INVALID_ACTION_MESSAGE }); return;
}
const targetErr = validateActionTarget(action, target_type);
@@ -258,7 +242,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
res.status(400).json({ error: 'Invalid action' }); return;
}
const finalAction = (action ?? existing.action) as ScheduledAction;
const finalAction = (action ?? existing.action) as BackendScheduledAction;
const finalTargetType = (target_type ?? existing.target_type) as TargetType;
const finalTargetId = target_id !== undefined ? target_id : existing.target_id;
const finalNodeId = node_id !== undefined ? node_id : existing.node_id;
+2 -1
View File
@@ -6,6 +6,7 @@ import { isSeverityAtLeast } from '../utils/severity';
import { evaluatePolicyRisk, policyInputs } from '../utils/policy-risk';
import type { AuditStatsInput } from './AuditAnomalyService';
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
import type { BackendScheduledAction } from './scheduledActionRegistry';
function isPilotMode(): boolean {
return process.env.SENCHO_MODE === 'pilot';
@@ -505,7 +506,7 @@ export interface ScheduledTask {
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
action: BackendScheduledAction;
cron_expression: string;
enabled: number;
created_by: string;
@@ -0,0 +1,79 @@
/**
* Single source of truth for scheduled-operation action metadata that the
* backend needs for validation. The route layer derives its allow-list and
* action/target compatibility checks from this table, so adding a new action
* means adding one entry here (plus its execution logic in SchedulerService).
*
* The frontend keeps its own richer registry (labels, categories, tones) in
* `frontend/src/lib/scheduledActions.ts`; the two cannot share a module because
* the packages build in isolation. The shapes are kept in lockstep by tests on
* each side.
*/
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
export type TargetType = typeof VALID_TARGET_TYPES[number];
interface BackendScheduledActionDefinition {
readonly id: string;
/** Target types this action accepts. `update` is the only multi-target action. */
readonly targetTypes: readonly TargetType[];
}
/**
* Ordered so the `VALID_ACTIONS` list matches the human-readable error message
* in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ...").
*/
export const BACKEND_SCHEDULED_ACTIONS = [
{ id: 'restart', targetTypes: ['stack'] },
{ id: 'snapshot', targetTypes: ['fleet'] },
{ id: 'prune', targetTypes: ['system'] },
{ id: 'update', targetTypes: ['stack', 'fleet'] },
{ id: 'scan', targetTypes: ['system'] },
{ id: 'auto_backup', targetTypes: ['stack'] },
{ id: 'auto_stop', targetTypes: ['stack'] },
{ id: 'auto_down', targetTypes: ['stack'] },
{ id: 'auto_start', targetTypes: ['stack'] },
] as const satisfies readonly BackendScheduledActionDefinition[];
export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id'];
export const VALID_ACTIONS: readonly BackendScheduledAction[] =
BACKEND_SCHEDULED_ACTIONS.map(a => a.id);
/**
* Human-readable allow-list for the route's 400 response. Built from
* VALID_ACTIONS so a new action cannot leave this enumeration stale.
*/
export const INVALID_ACTION_MESSAGE =
`Invalid action. Must be ${VALID_ACTIONS.join(', ').replace(/, ([^,]+)$/, ', or $1')}.`;
const ACTION_BY_ID = new Map<BackendScheduledAction, BackendScheduledActionDefinition>(
BACKEND_SCHEDULED_ACTIONS.map(a => [a.id, a]),
);
/**
* Per-action mismatch message. The wording differs per action and is part of
* the API contract, so it is kept explicit rather than templated.
*/
const TARGET_MISMATCH_MESSAGE: Record<BackendScheduledAction, string> = {
restart: 'Restart action requires target_type "stack".',
snapshot: 'Snapshot action requires target_type "fleet".',
prune: 'Prune action requires target_type "system".',
update: 'Update action requires target_type "stack" or "fleet".',
scan: 'Scan action requires target_type "system".',
auto_backup: 'auto_backup action requires target_type "stack".',
auto_stop: 'auto_stop action requires target_type "stack".',
auto_down: 'auto_down action requires target_type "stack".',
auto_start: 'auto_start action requires target_type "stack".',
};
/**
* Validate that the target_type is compatible with the action. Returns an error
* message on mismatch and null otherwise. Callers must already have confirmed
* the action is in `VALID_ACTIONS`.
*/
export function validateActionTarget(action: BackendScheduledAction, targetType: TargetType): string | null {
const def = ACTION_BY_ID.get(action);
if (!def) return null;
return def.targetTypes.includes(targetType) ? null : TARGET_MISMATCH_MESSAGE[action];
}
+2 -2
View File
@@ -18,7 +18,7 @@ Schedules is a unified surface for every recurring maintenance operation Sencho
Open the **Schedules** tab from the top navigation bar. The page opens on the Timeline view with the masthead, the five lane track, and a bottom time axis.
<Frame>
<img src="/images/scheduled-operations/timeline.png" alt="Schedules page in Timeline view. Header reads 'Scheduled Operations' on the left with a clock icon; on the right are a Timeline / All tasks toggle, a Refresh button, and a 'New Schedule' primary button. Below, a kicker 'NEXT 24 HOURS' sits above an italic display heading 'Next 24 hours' and a monospace date range 'Thu, May 14 11:21 → Fri, May 15 11:21'. A right-aligned 'Next' pill shows '03:00' with the subtitle 'Vul Scan · in 15h 38m'. Five lanes (Restart, Update, Scan, Prune, Lifecycle) run horizontally; a purple '03:00 Vul Scan' pill sits on the Scan lane and an amber '04:00 Nightly Snapshot' pill sits on the Prune lane. A cyan vertical 'now' rail glows at the left edge. Six time ticks (11:21, 16:09, 20:57, 01:45, 06:33, 11:21) run along the bottom axis." />
<img src="/images/scheduled-operations/timeline.png" alt="Schedules page in Timeline view. Header reads 'Scheduled Operations' on the left with a clock icon; on the right are a Timeline / All tasks toggle, a Refresh button, and a 'New Schedule' primary button. Below, a kicker 'NEXT 24 HOURS' sits above an italic display heading 'Next 24 hours' and a monospace date range 'Thu, May 14 11:21 → Fri, May 15 11:21'. A right-aligned 'Next' pill shows '03:00' with the subtitle 'Vul Scan · in 15h 38m'. Five lanes (Lifecycle, Updates, Security, Maintenance, Backups) run horizontally; a purple '03:00 Vul Scan' pill sits on the Security lane and a cyan '04:00 Nightly Snapshot' pill sits on the Backups lane. A cyan vertical 'now' rail glows at the left edge. Six time ticks (11:21, 16:09, 20:57, 01:45, 06:33, 11:21) run along the bottom axis." />
</Frame>
## Timeline view
@@ -26,7 +26,7 @@ Open the **Schedules** tab from the top navigation bar. The page opens on the Ti
The Timeline plots every firing of every enabled task across a rolling 24-hour window starting from the current minute.
- **Masthead.** A `NEXT 24 HOURS` kicker, an italic display heading, the window's start and end timestamps in a monospace range, and a right-anchored **Next** pill that reads out the time and task name of the next firing and a relative countdown.
- **Five lanes.** Restart (brand cyan), Update (success green), Scan (label purple), Prune (warning amber), and Lifecycle (label blue). Snapshot tasks share the Prune lane; the four stack-lifecycle actions (Backup Stack Files, Stop Stack, Take Stack Down, Start Stack) share the Lifecycle lane.
- **Five lanes.** Lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Lifecycle lane holds stack restarts plus the four stack-lifecycle actions (Backup Stack Files, Stop Stack, Take Stack Down, Start Stack); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds system prunes; Backups holds fleet snapshots.
- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and the task name. Pills are color-matched to their lane. Click a pill to open the run history sheet for that task.
- **Now rail.** A glowing vertical rail at the current minute, anchored to the left of the track at page open and drifting right as time passes (the page recomputes positions periodically).
- **Axis.** Six monospace time ticks run along the bottom, evenly spaced through the window.
@@ -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();
});
});
@@ -0,0 +1,67 @@
/**
* Locks the frontend scheduled-action registry: the task-to-definition
* resolution (including the update-fleet alias), and the parity between the
* registry's backend actions and the wire action union.
*/
import { describe, it, expect } from 'vitest';
import {
SCHEDULED_ACTIONS,
SCHEDULED_ACTION_CATEGORIES,
getActionById,
resolveTaskAction,
type BackendAction,
type ScheduledActionCategory,
} from '../scheduledActions';
const BACKEND_ACTIONS: BackendAction[] = [
'restart', 'snapshot', 'prune', 'update', 'scan',
'auto_backup', 'auto_stop', 'auto_down', 'auto_start',
];
const CATEGORY_KEYS: ScheduledActionCategory[] = SCHEDULED_ACTION_CATEGORIES.map(c => c.key);
describe('scheduledActions registry', () => {
it('every backendAction is a known wire action', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(BACKEND_ACTIONS).toContain(def.backendAction);
}
});
it('covers every wire action with at least one entry', () => {
const covered = new Set(SCHEDULED_ACTIONS.map(d => d.backendAction));
expect([...covered].sort()).toEqual([...BACKEND_ACTIONS].sort());
});
it('every entry uses a defined category lane', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(CATEGORY_KEYS).toContain(def.category);
}
});
it('getActionById resolves a known id and returns undefined otherwise', () => {
expect(getActionById('restart')?.label).toBe('Restart Stack');
expect(getActionById('nope')).toBeUndefined();
});
describe('resolveTaskAction', () => {
it('maps update + fleet to the update-fleet UI entry', () => {
const def = resolveTaskAction({ action: 'update', target_type: 'fleet' });
expect(def?.id).toBe('update-fleet');
expect(def?.backendAction).toBe('update');
});
it('maps update + stack to the direct update entry', () => {
const def = resolveTaskAction({ action: 'update', target_type: 'stack' });
expect(def?.id).toBe('update');
});
it('maps a non-aliased action to its direct entry', () => {
expect(resolveTaskAction({ action: 'restart', target_type: 'stack' })?.id).toBe('restart');
expect(resolveTaskAction({ action: 'snapshot', target_type: 'fleet' })?.id).toBe('snapshot');
});
it('returns undefined for an unknown action', () => {
expect(resolveTaskAction({ action: 'bogus' as BackendAction, target_type: 'system' })).toBeUndefined();
});
});
});
+91
View File
@@ -0,0 +1,91 @@
import type { ScheduledTask } from '@/types/scheduling';
/**
* Single source of truth for scheduled-operation action metadata on the
* frontend. Drives the create-flow action picker, the All Tasks label column,
* the Timeline lanes, and the mobile schedule labels/tones. Adding a new action
* means adding one entry here (plus its execution logic on the backend).
*
* The backend keeps a leaner validation-only registry in
* `backend/src/services/scheduledActionRegistry.ts`; the packages build in
* isolation so the two cannot share a module. Tests on each side keep the
* action sets in lockstep.
*/
/** Backend action ids (the 9 values that travel on the wire). */
export type BackendAction = ScheduledTask['action'];
/**
* UI action ids. `update-fleet` is a frontend-only alias for `update` with
* `target_type: 'fleet'`; it never reaches the backend.
*/
export type ScheduledActionId = BackendAction | 'update-fleet';
export type ScheduledActionCategory = 'lifecycle' | 'updates' | 'security' | 'maintenance' | 'backups';
export type ScheduledActionTone = 'success' | 'warning' | 'destructive' | 'brand';
export interface ScheduledActionDefinition {
id: ScheduledActionId;
/** The action value sent to the backend (`update-fleet` maps to `update`). */
backendAction: BackendAction;
label: string;
/** Compact label used on the mobile schedule view. */
shortLabel: string;
category: ScheduledActionCategory;
targetType: ScheduledTask['target_type'];
tone: ScheduledActionTone;
requiresNode: boolean;
requiresStack: boolean;
supportsServiceSelection: boolean;
helperText?: string;
}
/** Ordered for the create-flow action picker. */
export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [
{ id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: true },
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks', shortLabel: 'update', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every stack on the selected node will be checked and updated when new images are available.' },
{ id: 'snapshot', backendAction: 'snapshot', label: 'Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false },
{ id: 'prune', backendAction: 'prune', label: 'System Prune', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false },
{ id: 'scan', backendAction: 'scan', label: 'Vulnerability Scan', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every image on the selected node will be scanned.' },
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack (keep containers)', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down (remove containers)', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
{ id: 'auto_start', backendAction: 'auto_start', label: 'Start Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
];
const ACTION_BY_ID = new Map<string, ScheduledActionDefinition>(SCHEDULED_ACTIONS.map(a => [a.id, a]));
export function getActionById(id: string): ScheduledActionDefinition | undefined {
return ACTION_BY_ID.get(id);
}
/**
* Resolve a stored task to its action definition. A stored `update` task with a
* `fleet` target maps to the `update-fleet` UI entry; everything else maps by
* its backend action id.
*/
export function resolveTaskAction(
task: Pick<ScheduledTask, 'action' | 'target_type'>,
): ScheduledActionDefinition | undefined {
if (task.action === 'update' && task.target_type === 'fleet') {
return getActionById('update-fleet');
}
return getActionById(task.action);
}
export interface ScheduledActionCategoryLane {
key: ScheduledActionCategory;
label: string;
color: string;
bg: string;
}
/** Ordered Timeline lanes; each scheduled action maps to one lane by category. */
export const SCHEDULED_ACTION_CATEGORIES: ScheduledActionCategoryLane[] = [
{ key: 'lifecycle', label: 'Lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)' },
{ key: 'updates', label: 'Updates', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)' },
{ key: 'security', label: 'Security', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)' },
{ key: 'maintenance', label: 'Maintenance', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)' },
{ key: 'backups', label: 'Backups', color: 'var(--brand)', bg: 'oklch(from var(--brand) l c h / 0.18)' },
];