mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
feat(scheduler): schedule container restart, stop, and start (#1526)
* 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.
This commit is contained in:
@@ -12,6 +12,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download, CalendarClock, Table2 } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { excludeLikelySenchoContainers } from '@/lib/senchoContainerFilter';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
|
||||
@@ -46,6 +47,18 @@ const DEFAULT_SIMPLE_SCHEDULE: SimpleSchedule = {
|
||||
const TIMELINE_WINDOW_HOURS = 24;
|
||||
const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000;
|
||||
|
||||
interface ContainerListItem {
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
State?: string;
|
||||
Image?: string;
|
||||
Labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
function containerDisplayName(c: ContainerListItem): string {
|
||||
return c.Names?.[0]?.replace(/^\//, '') || c.Id.slice(0, 12);
|
||||
}
|
||||
|
||||
function formatHourTick(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
@@ -106,8 +119,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const [runsTotal, setRunsTotal] = useState(0);
|
||||
const runsLimit = 20;
|
||||
|
||||
// Available stacks and nodes for selection
|
||||
// Available stacks, containers, and nodes for selection
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
const [containers, setContainers] = useState<ContainerListItem[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
|
||||
const filteredTasks = filterNodeId != null
|
||||
@@ -146,6 +160,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchContainers = useCallback(async (nodeId: string) => {
|
||||
try {
|
||||
const res = await fetchForNode('/containers?all=true', parseInt(nodeId, 10));
|
||||
if (res.ok) {
|
||||
const rows = (await res.json()) as ContainerListItem[];
|
||||
setContainers(excludeLikelySenchoContainers(rows));
|
||||
} else {
|
||||
setContainers([]);
|
||||
}
|
||||
} catch {
|
||||
setContainers([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/nodes', { localOnly: true });
|
||||
@@ -192,7 +220,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
? await fetchForNode(endpoint, parseInt(formNodeId, 10))
|
||||
: await apiFetch(endpoint);
|
||||
if (res.ok && !cancelled) {
|
||||
setAvailableServices(await res.json());
|
||||
const services = (await res.json()) as string[];
|
||||
setAvailableServices(services);
|
||||
if (services.length <= 1) {
|
||||
setFormTargetServices([]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
@@ -202,17 +234,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
return () => { cancelled = true; };
|
||||
}, [formAction, formTargetId, formNodeId]);
|
||||
|
||||
// Re-fetch stacks when the node changes. Clearing a stale stack selection is
|
||||
// done in the Node picker's onValueChange (a user-driven change), not here, so
|
||||
// a prefilled or edited node keeps its stack instead of being wiped on open.
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
if (formNodeId) {
|
||||
const actionDef = getActionById(formAction);
|
||||
if (actionDef?.requiresContainer && formNodeId) {
|
||||
fetchContainers(formNodeId);
|
||||
fetchStacks(formNodeId);
|
||||
} else if (formNodeId) {
|
||||
fetchStacks(formNodeId);
|
||||
setContainers([]);
|
||||
} else {
|
||||
setStacks([]);
|
||||
setContainers([]);
|
||||
}
|
||||
}, [formNodeId, dialogOpen, fetchStacks]);
|
||||
}, [formNodeId, formAction, dialogOpen, fetchStacks, fetchContainers]);
|
||||
|
||||
const openCreate = (prefillData?: { stackName: string; nodeId: string }) => {
|
||||
const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : '');
|
||||
@@ -299,7 +334,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
enabled: formEnabled,
|
||||
delete_after_run: formDeleteAfterRun,
|
||||
run_at: runAt,
|
||||
target_id: actionDef.requiresStack ? formTargetId : null,
|
||||
target_id: (actionDef.requiresStack || actionDef.requiresContainer) ? formTargetId : null,
|
||||
node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null,
|
||||
prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null,
|
||||
target_services: actionDef.supportsServiceSelection && formTargetServices.length > 0 ? formTargetServices : null,
|
||||
@@ -453,13 +488,31 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
[nodes],
|
||||
);
|
||||
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
|
||||
const containerOptions = useMemo(
|
||||
() => containers.map(c => {
|
||||
const name = containerDisplayName(c);
|
||||
const state = c.State ?? 'unknown';
|
||||
const image = (c.Image ?? '').split('@')[0];
|
||||
return { value: name, label: `${name} · ${state} · ${image}` };
|
||||
}),
|
||||
[containers],
|
||||
);
|
||||
const selectedContainer = useMemo(
|
||||
() => containers.find(c => containerDisplayName(c) === formTargetId),
|
||||
[containers, formTargetId],
|
||||
);
|
||||
const selectedContainerStack = selectedContainer?.Labels?.['com.docker.compose.project'];
|
||||
const isUnmanagedContainer = !!selectedContainer && (
|
||||
!selectedContainerStack || !stacks.includes(selectedContainerStack)
|
||||
);
|
||||
const scheduleInvalid = scheduleMode === 'simple'
|
||||
? !!simpleCronError
|
||||
: (!formCron || !!cronFieldError);
|
||||
const isSaveDisabled =
|
||||
saving || !currentAction || !formName || scheduleInvalid
|
||||
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
||||
|| (!!currentAction?.requiresContainer && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && !formNodeId)
|
||||
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
||||
|
||||
const windowEnd = now + TIMELINE_WINDOW_MS;
|
||||
@@ -699,6 +752,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
? task.target_services
|
||||
? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})`
|
||||
: task.target_id
|
||||
: task.target_type === 'container'
|
||||
? task.target_id
|
||||
: task.action === 'update'
|
||||
? 'All eligible stacks'
|
||||
: task.target_type}
|
||||
@@ -799,6 +854,40 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentAction?.requiresContainer && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodeOptions}
|
||||
value={formNodeId}
|
||||
onValueChange={(val) => { setFormNodeId(val); setFormTargetId(''); }}
|
||||
placeholder="Select node..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Container</Label>
|
||||
<Combobox
|
||||
options={containerOptions}
|
||||
value={formTargetId}
|
||||
onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }}
|
||||
placeholder={formNodeId ? 'Select container...' : 'Select a node first'}
|
||||
disabled={!formNodeId}
|
||||
/>
|
||||
</div>
|
||||
{isUnmanagedContainer && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This container is not associated with a Sencho stack. The schedule will target the container by node and name.
|
||||
</p>
|
||||
)}
|
||||
{selectedContainerStack && stacks.includes(selectedContainerStack) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Part of stack: {selectedContainerStack}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresStack && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -815,12 +904,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<Combobox
|
||||
options={stacks.map(s => ({ value: s, label: s }))}
|
||||
value={formTargetId}
|
||||
onValueChange={setFormTargetId}
|
||||
onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }}
|
||||
placeholder={formNodeId ? "Select stack..." : "Select a node first"}
|
||||
disabled={!formNodeId}
|
||||
/>
|
||||
</div>
|
||||
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 0 && (
|
||||
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 1 && (
|
||||
<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">
|
||||
@@ -853,7 +942,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && (
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* hub-local endpoint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ScheduledTask } from '@/types/scheduling';
|
||||
|
||||
@@ -338,10 +338,42 @@ describe('ScheduledOperationsView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hides service checkboxes when the stack has only one service', async () => {
|
||||
mockedFetchForNode.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/services')) return jsonResponse(['mariadb']);
|
||||
return jsonResponse(['db-compose']);
|
||||
});
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[2]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'db-compose' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFetchForNode).toHaveBeenCalledWith('/stacks/db-compose/services', 2),
|
||||
);
|
||||
expect(screen.queryByText(/^Services/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows service checkboxes when the stack has multiple services', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[2]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'web' }));
|
||||
|
||||
const servicesBlock = (await screen.findByText(/^Services/)).closest<HTMLElement>('.space-y-2');
|
||||
expect(within(servicesBlock!).getAllByRole('checkbox')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders the five registry category lanes in the timeline view', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
// Timeline is the default view; the lane track always renders.
|
||||
for (const lane of ['Stack lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
|
||||
for (const lane of ['Lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
|
||||
expect(await screen.findByText(lane)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
@@ -425,6 +457,54 @@ describe('ScheduledOperationsView', () => {
|
||||
await selectAction('Prune Node Resources');
|
||||
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||
|
||||
// Container action: Node + Container, no Stack.
|
||||
await selectAction('Restart Container');
|
||||
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Container')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits container target payload for a container restart save', async () => {
|
||||
mockedFetchForNode.mockImplementation(async (url: string) => {
|
||||
if (url === '/stacks') return jsonResponse(['web']);
|
||||
if (url.startsWith('/containers')) {
|
||||
return jsonResponse([
|
||||
{ Id: 'abc', Names: ['/watchtower'], State: 'running', Image: 'containrrr/watchtower' },
|
||||
]);
|
||||
}
|
||||
return jsonResponse([]);
|
||||
});
|
||||
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'daily-watchtower');
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Restart Container' }));
|
||||
|
||||
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: /watchtower/ }));
|
||||
|
||||
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: 'daily-watchtower',
|
||||
target_type: 'container',
|
||||
action: 'restart',
|
||||
target_id: 'watchtower',
|
||||
node_id: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('emits node_id and target_id for a stack update save', async () => {
|
||||
|
||||
@@ -71,6 +71,12 @@ describe('scheduledActions registry', () => {
|
||||
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');
|
||||
@@ -90,6 +96,7 @@ describe('scheduledActions registry', () => {
|
||||
// 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',
|
||||
@@ -111,6 +118,9 @@ describe('scheduledActions registry', () => {
|
||||
'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.',
|
||||
@@ -132,6 +142,9 @@ describe('scheduledActions registry', () => {
|
||||
'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',
|
||||
@@ -212,6 +225,16 @@ describe('scheduledActions registry', () => {
|
||||
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' };
|
||||
|
||||
@@ -19,7 +19,7 @@ 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 ScheduledActionId = BackendAction | 'update-fleet' | 'container-restart' | 'container-stop' | 'container-start';
|
||||
|
||||
export type ScheduledActionCategory = 'lifecycle' | 'updates' | 'security' | 'maintenance' | 'backups';
|
||||
export type ScheduledActionTone = 'success' | 'warning' | 'destructive' | 'brand';
|
||||
@@ -79,6 +79,7 @@ export interface ScheduledActionDefinition {
|
||||
tone: ScheduledActionTone;
|
||||
requiresNode: boolean;
|
||||
requiresStack: boolean;
|
||||
requiresContainer: boolean;
|
||||
supportsServiceSelection: boolean;
|
||||
nodeScope?: 'local';
|
||||
/** One-line explanation shown below the action picker in the create/edit form. */
|
||||
@@ -93,20 +94,23 @@ export const DEFAULT_SCHEDULED_ACTION_ID: ScheduledActionId = 'restart';
|
||||
/** Ordered for the create-flow action picker, grouped by category. */
|
||||
export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [
|
||||
// Lifecycle
|
||||
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' },
|
||||
{ id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' },
|
||||
{ id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' },
|
||||
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' },
|
||||
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' },
|
||||
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' },
|
||||
{ id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' },
|
||||
{ id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' },
|
||||
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' },
|
||||
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' },
|
||||
{ id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive' },
|
||||
{ id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive' },
|
||||
{ id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change' },
|
||||
// Updates
|
||||
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' },
|
||||
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' },
|
||||
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' },
|
||||
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' },
|
||||
// Security
|
||||
{ id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' },
|
||||
{ id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' },
|
||||
// Maintenance
|
||||
{ id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' },
|
||||
{ id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' },
|
||||
// Backups
|
||||
{ id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' },
|
||||
{ id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' },
|
||||
];
|
||||
|
||||
const ACTION_BY_ID = new Map<string, ScheduledActionDefinition>(SCHEDULED_ACTIONS.map(a => [a.id, a]));
|
||||
@@ -126,6 +130,11 @@ export function resolveTaskAction(
|
||||
if (task.action === 'update' && task.target_type === 'fleet') {
|
||||
return getActionById('update-fleet');
|
||||
}
|
||||
if (task.target_type === 'container') {
|
||||
if (task.action === 'restart') return getActionById('container-restart');
|
||||
if (task.action === 'auto_stop') return getActionById('container-stop');
|
||||
if (task.action === 'auto_start') return getActionById('container-start');
|
||||
}
|
||||
return getActionById(task.action);
|
||||
}
|
||||
|
||||
@@ -153,6 +162,8 @@ export function scheduleTargetDescriptor(
|
||||
: 'Entire fleet';
|
||||
case 'system':
|
||||
return nodeName ?? 'Selected node';
|
||||
case 'container':
|
||||
return task.target_id ?? task.name;
|
||||
default: {
|
||||
const exhaustive: never = task.target_type;
|
||||
return exhaustive;
|
||||
@@ -169,7 +180,7 @@ export interface ScheduledActionCategoryLane {
|
||||
|
||||
/** Ordered Timeline lanes; each scheduled action maps to one lane by category. */
|
||||
export const SCHEDULED_ACTION_CATEGORIES: ScheduledActionCategoryLane[] = [
|
||||
{ key: 'lifecycle', label: 'Stack lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)' },
|
||||
{ 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)' },
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { excludeLikelySenchoContainers, isLikelySenchoManagementContainer } from '../lib/senchoContainerFilter';
|
||||
|
||||
describe('senchoContainerFilter', () => {
|
||||
it('detects sencho management containers by name and image', () => {
|
||||
expect(isLikelySenchoManagementContainer({ Id: '1', Names: ['/sencho'], Image: 'saelix/sencho:latest' })).toBe(true);
|
||||
expect(isLikelySenchoManagementContainer({ Id: '2', Names: ['/sencho-agent'] })).toBe(true);
|
||||
expect(isLikelySenchoManagementContainer({ Id: '3', Names: ['/mariadb'], Image: 'lscr.io/linuxserver/mariadb:latest' })).toBe(false);
|
||||
});
|
||||
|
||||
it('excludeLikelySenchoContainers keeps user containers', () => {
|
||||
const rows = excludeLikelySenchoContainers([
|
||||
{ Id: '1', Names: ['/sencho'], Image: 'ghcr.io/studio-saelix/sencho:dev' },
|
||||
{ Id: '2', Names: ['/mariadb'], Image: 'lscr.io/linuxserver/mariadb:latest' },
|
||||
]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].Names).toEqual(['/mariadb']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Mirrors backend/helpers/excludeSelfContainers.ts heuristics for proxied remote lists. */
|
||||
|
||||
export interface ContainerPickerRow {
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
Image?: string;
|
||||
}
|
||||
|
||||
function isPublishedSenchoImage(image: string): boolean {
|
||||
const lower = image.toLowerCase();
|
||||
return /(?:^|\/)saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower)
|
||||
|| /studio-saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower);
|
||||
}
|
||||
|
||||
export function isLikelySenchoManagementContainer(c: ContainerPickerRow): boolean {
|
||||
const name = c.Names?.[0]?.replace(/^\//, '').toLowerCase() ?? '';
|
||||
if (name === 'sencho' || name === 'sencho-agent') return true;
|
||||
if (c.Image && isPublishedSenchoImage(c.Image)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function excludeLikelySenchoContainers<T extends ContainerPickerRow>(containers: T[]): T[] {
|
||||
return containers.filter(c => !isLikelySenchoManagementContainer(c));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface ScheduledTask {
|
||||
id: number;
|
||||
name: string;
|
||||
target_type: 'stack' | 'fleet' | 'system';
|
||||
target_type: 'stack' | 'fleet' | 'system' | 'container';
|
||||
target_id: string | null;
|
||||
node_id: number | null;
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
|
||||
|
||||
Reference in New Issue
Block a user