mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user