mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)' },
|
||||
];
|
||||
Reference in New Issue
Block a user