mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user