mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
10fb93dcb1
* feat(scheduler): schedule container restart, stop, and start Add container as a scheduled-task target type so operators can automate lifecycle actions against standalone containers by node and name, with matching UI pickers, validation, execution on local and remote nodes, and tests. * fix(scheduler): stack service matching and container picker hygiene Backfill Service on smartFallback containers so per-service stack restarts work when container_name is set. Match services by compose label and container name in stack routes and scheduled restarts. Exclude Sencho from GET /api/containers lists. Hide the Restart Stack service picker when a stack has only one service. * test(scheduler): scope service checkbox assertion to Services block The create dialog also has a Delete after run checkbox. Count checkboxes only inside the Services section so CI does not include unrelated form controls. * fix(scheduler): narrow closest() result to HTMLElement in schedule test The service-checkbox assertion passed an Element from closest() into within(), which requires an HTMLElement, failing tsc -b in the frontend build and Docker build stages. Use the closest<HTMLElement>() type argument so the value type-checks without an unsafe cast. * fix(scheduler): hide Sencho container on remote node picker lists Remote container lists are proxied from peer Sencho instances, so id-only self filtering missed peers on older builds. Await SelfIdentity init, match ImageID, and drop official saelix/sencho images. Apply the same heuristic in the scheduled-operations UI and when the hub fetches remote containers for scheduled runs. * test(monitor): add missing DatabaseService mocks for scan history cleanup * test(scheduler): add missing markStaleScansAsFailed mock SchedulerService.tick() calls db.markStaleScansAsFailed() to sweep stale vulnerability scans. The scheduler-service test was missing this method in its DatabaseService mock, causing TypeError failures during test initialization. Added mockMarkStaleScansAsFailed to hoisted mocks and DatabaseService mock object, returning safe default of 0 scans marked as failed. * test(compose): add missing FileSystemService mocks for getStackContent/getEnvContent * test(containers-route): mock SelfIdentityService to prevent initialize() crash The excludeSelfContainers() helper calls SelfIdentityService.initialize(), which tries to access DockerController. Without a proper SelfIdentityService mock, the initialize() call fails silently, causing a 500 error on GET /api/containers. Added SelfIdentityService mock with initialize(), isOwnContainer(), and isOwnImage() methods to prevent the crash.
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
/**
|
|
* 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,
|
|
DEFAULT_SCHEDULED_ACTION_ID,
|
|
getActionById,
|
|
resolveTaskAction,
|
|
scheduleTargetDescriptor,
|
|
stripComposeExt,
|
|
RISK_LABEL,
|
|
RISK_TONE,
|
|
RISK_BADGE_CLASSES,
|
|
RISK_DOT_CLASSES,
|
|
type BackendAction,
|
|
type ScheduledActionCategory,
|
|
type ScheduledActionRiskLevel,
|
|
} from '../scheduledActions';
|
|
import type { ScheduledTask } from '@/types/scheduling';
|
|
|
|
type TargetTask = Pick<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'name'>;
|
|
|
|
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('marks scan and prune as local-node actions', () => {
|
|
expect(getActionById('scan')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
|
|
expect(getActionById('prune')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
|
|
});
|
|
|
|
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 container target types to container UI entries', () => {
|
|
expect(resolveTaskAction({ action: 'restart', target_type: 'container' })?.id).toBe('container-restart');
|
|
expect(resolveTaskAction({ action: 'auto_stop', target_type: 'container' })?.id).toBe('container-stop');
|
|
expect(resolveTaskAction({ action: 'auto_start', target_type: 'container' })?.id).toBe('container-start');
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
it('exports DEFAULT_SCHEDULED_ACTION_ID as restart', () => {
|
|
expect(DEFAULT_SCHEDULED_ACTION_ID).toBe('restart');
|
|
});
|
|
|
|
it('orders actions by category group', () => {
|
|
const ids = SCHEDULED_ACTIONS.map(a => a.id);
|
|
// Verify the exact order: lifecycle first, then updates, security, maintenance, backups.
|
|
expect(ids).toEqual([
|
|
'auto_backup', 'auto_start', 'restart', 'auto_stop', 'auto_down',
|
|
'container-restart', 'container-stop', 'container-start',
|
|
'update', 'update-fleet',
|
|
'scan',
|
|
'prune',
|
|
'snapshot',
|
|
]);
|
|
});
|
|
|
|
it('preserves the update-fleet alias after resorting', () => {
|
|
const fleetUpdate = SCHEDULED_ACTIONS.find(a => a.id === 'update-fleet');
|
|
expect(fleetUpdate).toBeDefined();
|
|
expect(fleetUpdate!.backendAction).toBe('update');
|
|
expect(fleetUpdate!.targetType).toBe('fleet');
|
|
});
|
|
|
|
describe('helperText', () => {
|
|
const expected: Record<string, string> = {
|
|
'auto_backup': 'Backs up compose and env files only. This does not back up application volumes.',
|
|
'auto_start': 'Creates containers if they do not exist, or starts existing stopped containers.',
|
|
'restart': 'Restarts containers in place. Running services are stopped and started again on the same configuration.',
|
|
'auto_stop': 'Stops containers but keeps them in place for a faster start later.',
|
|
'auto_down': 'Runs compose down. Containers are removed, but compose files remain on disk.',
|
|
'container-restart': 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.',
|
|
'container-stop': 'Stops a single container by name. The container remains on disk for a faster start later.',
|
|
'container-start': 'Starts a stopped container by name on the selected node.',
|
|
'update': "Checks this stack's images and recreates the stack only when newer images are available.",
|
|
'update-fleet': 'Checks every stack on the selected node and updates stacks with newer images.',
|
|
'scan': 'Runs Trivy against images on the selected local node and records the findings.',
|
|
'prune': 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.',
|
|
'snapshot': 'Creates a versioned snapshot of compose and env files across the fleet.',
|
|
};
|
|
|
|
for (const [id, text] of Object.entries(expected)) {
|
|
it(`${id} helper text matches the specified wording`, () => {
|
|
expect(getActionById(id)?.helperText).toBe(text);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('riskLevel', () => {
|
|
const expected: Record<string, ScheduledActionRiskLevel> = {
|
|
'auto_backup': 'safe',
|
|
'auto_start': 'runtime-change',
|
|
'restart': 'interruptive',
|
|
'auto_stop': 'interruptive',
|
|
'auto_down': 'removes-containers',
|
|
'container-restart': 'interruptive',
|
|
'container-stop': 'interruptive',
|
|
'container-start': 'runtime-change',
|
|
'update': 'runtime-change',
|
|
'update-fleet': 'runtime-change',
|
|
'scan': 'read-only',
|
|
'prune': 'destructive',
|
|
'snapshot': 'safe',
|
|
};
|
|
|
|
for (const [id, level] of Object.entries(expected)) {
|
|
it(`${id} risk level is ${level}`, () => {
|
|
expect(getActionById(id)?.riskLevel).toBe(level);
|
|
});
|
|
}
|
|
|
|
it('every action has a RISK_LABEL entry', () => {
|
|
for (const def of SCHEDULED_ACTIONS) {
|
|
expect(RISK_LABEL[def.riskLevel]).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('risk metadata maps', () => {
|
|
it('RISK_TONE maps every action risk level', () => {
|
|
for (const def of SCHEDULED_ACTIONS) {
|
|
expect(RISK_TONE[def.riskLevel]).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('RISK_BADGE_CLASSES maps every action risk level', () => {
|
|
for (const def of SCHEDULED_ACTIONS) {
|
|
expect(RISK_BADGE_CLASSES[def.riskLevel]).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('RISK_DOT_CLASSES maps every action risk level', () => {
|
|
for (const def of SCHEDULED_ACTIONS) {
|
|
expect(RISK_DOT_CLASSES[def.riskLevel]).toBeTruthy();
|
|
}
|
|
});
|
|
});
|
|
|
|
it('resolveTaskAction for update+fleet returns correct metadata', () => {
|
|
const def = resolveTaskAction({ action: 'update', target_type: 'fleet' });
|
|
expect(def?.id).toBe('update-fleet');
|
|
expect(def?.riskLevel).toBe('runtime-change');
|
|
expect(def?.helperText).toBe('Checks every stack on the selected node and updates stacks with newer images.');
|
|
});
|
|
|
|
describe('stripComposeExt', () => {
|
|
it('drops a trailing .yml or .yaml and leaves other names alone', () => {
|
|
expect(stripComposeExt('web')).toBe('web');
|
|
expect(stripComposeExt('web.yml')).toBe('web');
|
|
expect(stripComposeExt('web.yaml')).toBe('web');
|
|
expect(stripComposeExt('')).toBe('');
|
|
expect(stripComposeExt('my.app')).toBe('my.app');
|
|
});
|
|
});
|
|
|
|
describe('scheduleTargetDescriptor', () => {
|
|
it('shows the stack name (without compose extension) for stack actions', () => {
|
|
const task: TargetTask = { action: 'restart', target_type: 'stack', target_id: 'web.yml', name: 'Nightly restart' };
|
|
expect(scheduleTargetDescriptor(task, 'hub')).toBe('web');
|
|
});
|
|
|
|
it('falls back to the task name when a stack target_id is missing', () => {
|
|
const task: TargetTask = { action: 'restart', target_type: 'stack', target_id: null, name: 'api.yaml' };
|
|
expect(scheduleTargetDescriptor(task)).toBe('api');
|
|
});
|
|
|
|
it('shows the node for a fleet auto-update, or a generic label without one', () => {
|
|
const task: TargetTask = { action: 'update', target_type: 'fleet', target_id: null, name: 'Fleet update' };
|
|
expect(scheduleTargetDescriptor(task, 'edge-1')).toBe('All stacks · edge-1');
|
|
expect(scheduleTargetDescriptor(task)).toBe('All stacks');
|
|
});
|
|
|
|
it('shows Entire fleet for a fleet snapshot regardless of node', () => {
|
|
const task: TargetTask = { action: 'snapshot', target_type: 'fleet', target_id: null, name: 'Nightly Snapshot' };
|
|
expect(scheduleTargetDescriptor(task, 'edge-1')).toBe('Entire fleet');
|
|
expect(scheduleTargetDescriptor(task)).toBe('Entire fleet');
|
|
});
|
|
|
|
it('shows the container name for container actions', () => {
|
|
const task: TargetTask = {
|
|
action: 'restart',
|
|
target_type: 'container',
|
|
target_id: 'watchtower',
|
|
name: 'Daily watchtower restart',
|
|
};
|
|
expect(scheduleTargetDescriptor(task, 'hub')).toBe('watchtower');
|
|
});
|
|
|
|
it('shows the node for system actions (prune / scan), with a fallback', () => {
|
|
const scan: TargetTask = { action: 'scan', target_type: 'system', target_id: null, name: 'Vul Scan' };
|
|
const prune: TargetTask = { action: 'prune', target_type: 'system', target_id: null, name: 'Nightly Prune' };
|
|
expect(scheduleTargetDescriptor(scan, 'hub')).toBe('hub');
|
|
expect(scheduleTargetDescriptor(prune, 'edge-1')).toBe('edge-1');
|
|
expect(scheduleTargetDescriptor(scan)).toBe('Selected node');
|
|
});
|
|
});
|
|
});
|