diff --git a/backend/src/__tests__/scheduled-action-registry.test.ts b/backend/src/__tests__/scheduled-action-registry.test.ts new file mode 100644 index 00000000..3f5a4110 --- /dev/null +++ b/backend/src/__tests__/scheduled-action-registry.test.ts @@ -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 = { + 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 = { + 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]); + } + } + }); + } + }); +}); diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index f875be9d..7da017fe 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -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(['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; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 43d8256d..79ace26a 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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; diff --git a/backend/src/services/scheduledActionRegistry.ts b/backend/src/services/scheduledActionRegistry.ts new file mode 100644 index 00000000..e5e71fdf --- /dev/null +++ b/backend/src/services/scheduledActionRegistry.ts @@ -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( + 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 = { + 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]; +} diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 50aaf8a2..031a4f6c 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -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. - 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. + 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. ## 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. diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 956c608b..99b8cb84 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -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 = { 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 ) : (
- {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 (
@@ -599,10 +575,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p {task.name} - {(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} @@ -691,14 +664,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
({ 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..." />
- {targetType === 'stack' && ( + {currentAction?.requiresStack && ( <>
@@ -719,7 +692,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p disabled={!formNodeId} />
- {formAction === 'restart' && formTargetId && availableServices.length > 0 && ( + {currentAction.supportsServiceSelection && formTargetId && availableServices.length > 0 && (
@@ -742,7 +715,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p )} - {formAction === UPDATE_FLEET_ACTION && ( + {currentAction?.requiresNode && !currentAction.requiresStack && (
-

Every stack on the selected node will be checked and updated when new images are available.

-
- )} - - {formAction === 'scan' && ( -
- - -

Every image on the selected node will be scanned.

+ {currentAction.helperText && ( +

{currentAction.helperText}

+ )}
)} diff --git a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx index 873fe65e..681086ff 100644 --- a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx +++ b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx @@ -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; @@ -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(); + // 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(); + + 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(); + 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(); + + 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(); + + 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, + }); + }); + }); }); diff --git a/frontend/src/components/mobile/MobileSchedules.tsx b/frontend/src/components/mobile/MobileSchedules.tsx index ae6d4fac..3d20f259 100644 --- a/frontend/src/components/mobile/MobileSchedules.tsx +++ b/frontend/src/components/mobile/MobileSchedules.tsx @@ -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 = { - 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 = { - 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 (
{day !== prevDay ? {day} : null} @@ -155,7 +138,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) { {hhmm(run.runAt)} - {ACTION_LABEL[run.task.action]}{` ${targetLabel(run.task)}`} + {actionShortLabel(run.task.action)}{` ${targetLabel(run.task)}`} {relative(run.runAt, now)}
diff --git a/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx b/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx new file mode 100644 index 00000000..4a62a7c6 --- /dev/null +++ b/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx @@ -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; + +function jsonResponse(body: unknown): Response { + return { ok: true, status: 200, json: async () => body } as unknown as Response; +} + +function makeTask(overrides: Partial = {}): 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(); + + 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(); + }); +}); diff --git a/frontend/src/lib/__tests__/scheduledActions.test.ts b/frontend/src/lib/__tests__/scheduledActions.test.ts new file mode 100644 index 00000000..11db6d03 --- /dev/null +++ b/frontend/src/lib/__tests__/scheduledActions.test.ts @@ -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(); + }); + }); +}); diff --git a/frontend/src/lib/scheduledActions.ts b/frontend/src/lib/scheduledActions.ts new file mode 100644 index 00000000..1c8e95fc --- /dev/null +++ b/frontend/src/lib/scheduledActions.ts @@ -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(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, +): 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)' }, +];