mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
feat(scheduler): consistent action targeting in Scheduled Operations (#1431)
Give every scheduled action an explicit, predictable target model (Action then Node then Stack then Options then Schedule): - System Prune now exposes a Node picker and requires a node, so it can no longer run silently on the default node. - Vulnerability Scan and System Prune list local nodes only; both run on the hub-local Docker daemon and reject remote nodes on the backend. - Restart Stack service discovery loads services from the selected node via fetchForNode instead of the active or local node. - Fleet Snapshot shows a read-only "Scope: Entire fleet" summary. Backend gains a shared local-node guard and prune node validation on create and update, plus an executor-level remote-node guard, so the frontend and backend validation now agree for every action.
This commit is contained in:
@@ -127,7 +127,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const res = await apiFetch('/nodes', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNodes(data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })));
|
||||
setNodes(data.map((n: { id: number; name: string; type: 'local' | 'remote' }) => ({ id: n.id, name: n.name, type: n.type })));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
@@ -160,7 +160,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
let cancelled = false;
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(formTargetId)}/services`);
|
||||
// Load services from the selected node so remote-node restart schedules
|
||||
// discover the right services instead of the hub's.
|
||||
const endpoint = `/stacks/${encodeURIComponent(formTargetId)}/services`;
|
||||
const res = formNodeId
|
||||
? await fetchForNode(endpoint, parseInt(formNodeId, 10))
|
||||
: await apiFetch(endpoint);
|
||||
if (res.ok && !cancelled) {
|
||||
setAvailableServices(await res.json());
|
||||
}
|
||||
@@ -170,7 +175,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
};
|
||||
fetchServices();
|
||||
return () => { cancelled = true; };
|
||||
}, [formAction, formTargetId]);
|
||||
}, [formAction, formTargetId, formNodeId]);
|
||||
|
||||
// Re-fetch stacks when node changes
|
||||
useEffect(() => {
|
||||
@@ -221,7 +226,10 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
|
||||
const handleSave = async () => {
|
||||
const actionDef = getActionById(formAction);
|
||||
if (!actionDef) return;
|
||||
if (!actionDef) {
|
||||
toast.error('This scheduled action is no longer supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName,
|
||||
@@ -230,24 +238,13 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
cron_expression: formCron,
|
||||
enabled: formEnabled,
|
||||
delete_after_run: formDeleteAfterRun,
|
||||
target_id: actionDef.requiresStack ? 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,
|
||||
prune_label_filter: formAction === 'prune' && formPruneLabelFilter.trim() ? formPruneLabelFilter.trim() : null,
|
||||
};
|
||||
|
||||
if (actionDef.requiresStack) {
|
||||
body.target_id = formTargetId;
|
||||
}
|
||||
if (actionDef.requiresNode) {
|
||||
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
|
||||
}
|
||||
if (formAction === 'prune' && formPruneTargets.length > 0) {
|
||||
body.prune_targets = formPruneTargets;
|
||||
}
|
||||
if (formAction === 'restart' && formTargetServices.length > 0) {
|
||||
body.target_services = formTargetServices;
|
||||
}
|
||||
if (formAction === 'prune' && formPruneLabelFilter.trim()) {
|
||||
body.prune_label_filter = formPruneLabelFilter.trim();
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = editingTask
|
||||
@@ -342,8 +339,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const currentAction = getActionById(formAction);
|
||||
const cronDescription = getCronDescription(formCron);
|
||||
const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]);
|
||||
// Scan and prune run on the hub-local Docker daemon only; remote nodes are excluded from their pickers.
|
||||
const localNodeOptions = useMemo(
|
||||
() => nodes.filter(n => n.type === 'local').map(n => ({ value: String(n.id), label: n.name })),
|
||||
[nodes],
|
||||
);
|
||||
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
|
||||
const isSaveDisabled =
|
||||
saving || !formName || !formCron
|
||||
saving || !currentAction || !formName || !formCron
|
||||
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
||||
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
||||
@@ -715,11 +718,21 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
</>
|
||||
)}
|
||||
|
||||
{formAction === 'snapshot' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Scope</Label>
|
||||
<div className="flex h-9 w-full items-center rounded-md border border-glass-border bg-input px-3 text-sm text-muted-foreground">
|
||||
Entire fleet
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Captures every node's compose and .env files. No node or stack to choose.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodeOptions}
|
||||
options={currentNodeOptions}
|
||||
value={formNodeId}
|
||||
onValueChange={setFormNodeId}
|
||||
placeholder="Select node..."
|
||||
|
||||
@@ -51,16 +51,17 @@ function makeTask(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
}
|
||||
|
||||
let tasksFixture: ScheduledTask[];
|
||||
let nodesFixture: { id: number; name: string }[];
|
||||
let nodesFixture: { id: number; name: string; type: 'local' | 'remote' }[];
|
||||
|
||||
beforeEach(() => {
|
||||
tasksFixture = [];
|
||||
nodesFixture = [{ id: 1, name: 'hub' }, { id: 2, name: 'edge' }];
|
||||
nodesFixture = [{ id: 1, name: 'hub', type: 'local' }, { id: 2, name: 'edge', type: 'remote' }];
|
||||
mockedFetch.mockReset();
|
||||
mockedFetchForNode.mockReset();
|
||||
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/scheduled-tasks' && opts?.method === 'POST') return jsonResponse({ id: 99 }, { status: 201 });
|
||||
if (/^\/scheduled-tasks\/\d+$/.test(url) && opts?.method === 'PUT') return jsonResponse({ id: Number(url.split('/').pop()) });
|
||||
if (url === '/scheduled-tasks') return jsonResponse(tasksFixture);
|
||||
if (url === '/nodes') return jsonResponse(nodesFixture);
|
||||
if (url === '/stacks') return jsonResponse([]);
|
||||
@@ -152,6 +153,10 @@ describe('ScheduledOperationsView', () => {
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
|
||||
// Prune is now node-scoped: pick the local node from its Node combobox.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -164,6 +169,7 @@ describe('ScheduledOperationsView', () => {
|
||||
name: 'cleanup',
|
||||
target_type: 'system',
|
||||
action: 'prune',
|
||||
node_id: 1,
|
||||
cron_expression: '0 3 * * *',
|
||||
prune_targets: ['containers', 'images', 'networks', 'volumes'],
|
||||
});
|
||||
@@ -171,6 +177,106 @@ describe('ScheduledOperationsView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Create disabled for a prune until a node is selected', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'cleanup');
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
|
||||
// Prune targets default to all four, but with no node the gate must hold.
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('excludes remote nodes from the System Prune node picker', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
|
||||
// Open the Node combobox; only the local node should be listed.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
expect(await screen.findByRole('button', { name: 'hub' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'edge' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes remote nodes from the Vulnerability Scan node picker', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Vulnerability Scan' }));
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
expect(await screen.findByRole('button', { name: 'hub' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'edge' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits a vulnerability scan create with the selected local node', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'scan-local');
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Vulnerability Scan' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
|
||||
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: 'scan-local',
|
||||
target_type: 'system',
|
||||
action: 'scan',
|
||||
node_id: 1,
|
||||
target_id: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
});
|
||||
expect(postCall![1].localOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a read-only "Entire fleet" scope for Fleet Snapshot', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Fleet Snapshot' }));
|
||||
|
||||
expect(await screen.findByText('Entire fleet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads Restart Stack services from the selected node', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
// Default action is Restart Stack. Pick the remote node, then a stack on it.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
|
||||
// The Stack combobox unlocks once a node is chosen; pick a stack to drive discovery.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[2]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'web' }));
|
||||
|
||||
// Service discovery must target the selected node, not the hub-local default.
|
||||
await waitFor(() =>
|
||||
expect(mockedFetchForNode).toHaveBeenCalledWith('/stacks/web/services', 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.
|
||||
@@ -217,10 +323,10 @@ describe('ScheduledOperationsView', () => {
|
||||
expect(screen.queryByText('Node')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
|
||||
|
||||
// Prune: Prune Targets shown, no Node.
|
||||
// Prune: local-only Node plus Prune Targets.
|
||||
await selectAction('System Prune');
|
||||
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Node')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits node_id and target_id for a stack update save', async () => {
|
||||
@@ -286,4 +392,40 @@ describe('ScheduledOperationsView', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stack-only fields when editing a restart task into a fleet snapshot', async () => {
|
||||
tasksFixture = [makeTask({
|
||||
id: 42,
|
||||
name: 'restart-web',
|
||||
target_type: 'stack',
|
||||
target_id: 'web',
|
||||
node_id: 1,
|
||||
action: 'restart',
|
||||
target_services: JSON.stringify(['web']),
|
||||
})];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Fleet Snapshot' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/scheduled-tasks/42' && opts?.method === 'PUT',
|
||||
);
|
||||
expect(putCall).toBeTruthy();
|
||||
const body = JSON.parse(putCall![1].body);
|
||||
expect(body).toMatchObject({
|
||||
target_type: 'fleet',
|
||||
action: 'snapshot',
|
||||
target_id: null,
|
||||
node_id: null,
|
||||
target_services: null,
|
||||
prune_targets: null,
|
||||
prune_label_filter: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user