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:
Anso
2026-07-02 22:31:29 -04:00
committed by GitHub
parent b65daf6845
commit 10fb93dcb1
29 changed files with 932 additions and 68 deletions
@@ -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 () => {