From bc8c051962f66191a3304eba1b2fa01e30bf0a75 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 24 Jun 2026 21:02:15 -0400 Subject: [PATCH] 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. --- .../src/__tests__/filesystem-backup.test.ts | 26 +++ .../scheduled-action-registry.test.ts | 8 + .../__tests__/scheduled-tasks-routes.test.ts | 148 +++++++++++++++++ .../src/__tests__/scheduler-service.test.ts | 26 +++ backend/src/routes/scheduledTasks.ts | 134 +++++++++++----- backend/src/services/FileSystemService.ts | 48 +++--- backend/src/services/SchedulerService.ts | 3 + .../src/services/scheduledActionRegistry.ts | 26 +-- docs/features/scheduled-operations.mdx | 11 +- .../components/ScheduledOperationsView.tsx | 57 ++++--- .../ScheduledOperationsView.test.tsx | 150 +++++++++++++++++- .../lib/__tests__/scheduledActions.test.ts | 5 + frontend/src/lib/scheduledActions.ts | 5 +- frontend/src/types/scheduling.ts | 1 + 14 files changed, 542 insertions(+), 106 deletions(-) diff --git a/backend/src/__tests__/filesystem-backup.test.ts b/backend/src/__tests__/filesystem-backup.test.ts index bfadfbf9..ae438121 100644 --- a/backend/src/__tests__/filesystem-backup.test.ts +++ b/backend/src/__tests__/filesystem-backup.test.ts @@ -24,6 +24,10 @@ vi.mock('../services/NodeRegistry', () => ({ }, })); +vi.mock('../utils/debug', () => ({ + isDebugEnabled: () => false, +})); + import { FileSystemService } from '../services/FileSystemService'; describe('FileSystemService backup location', () => { @@ -85,6 +89,28 @@ describe('FileSystemService backup location', () => { expect(typeof after.timestamp).toBe('number'); }); + it('aborts backup creation when a destination write fails', async () => { + const stackName = 'writefail'; + const stackDir = path.join(composeDir, stackName); + const backupCompose = path.join(dataDir, 'backups', '1', stackName, 'compose.yaml'); + await fsPromises.mkdir(stackDir, { recursive: true }); + await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n', 'utf-8'); + + const realWriteFile = fsPromises.writeFile.bind(fsPromises); + const writeSpy = vi.spyOn(fsPromises, 'writeFile').mockImplementation(async (file, data, options) => { + if (path.normalize(String(file)) === path.normalize(backupCompose)) { + throw new Error('disk full'); + } + return realWriteFile(file, data, options); + }); + try { + await expect(FileSystemService.getInstance().backupStackFiles(stackName)).rejects.toThrow(/Could not write backup compose.yaml/); + await expect(fsPromises.access(backupCompose)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + writeSpy.mockRestore(); + } + }); + it('scopes backups by node id when stack names overlap', async () => { const stackName = 'web'; const secondComposeDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'sencho-compose-')); diff --git a/backend/src/__tests__/scheduled-action-registry.test.ts b/backend/src/__tests__/scheduled-action-registry.test.ts index 3f5a4110..6ea16769 100644 --- a/backend/src/__tests__/scheduled-action-registry.test.ts +++ b/backend/src/__tests__/scheduled-action-registry.test.ts @@ -8,6 +8,7 @@ import { VALID_ACTIONS, BACKEND_SCHEDULED_ACTIONS, INVALID_ACTION_MESSAGE, + getScheduledActionDefinition, validateActionTarget, type BackendScheduledAction, type TargetType, @@ -36,6 +37,13 @@ describe('scheduledActionRegistry', () => { ); }); + it('marks node-scoped and local-only actions in backend metadata', () => { + expect(getScheduledActionDefinition('scan')).toMatchObject({ requiresNode: true, nodeScope: 'local' }); + expect(getScheduledActionDefinition('prune')).toMatchObject({ requiresNode: true, nodeScope: 'local' }); + expect(getScheduledActionDefinition('update')).toMatchObject({ requiresNode: true }); + expect(getScheduledActionDefinition('snapshot')).toMatchObject({ requiresNode: false }); + }); + describe('validateActionTarget', () => { const validPairs: Record = { restart: ['stack'], diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index 549ca56b..cc0fe6ab 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -215,6 +215,34 @@ describe('POST /api/scheduled-tasks', () => { expect(res.body.error).toMatch(/Scan action requires node_id/); }); + for (const badNodeId of [true, '1.5', 0]) { + it(`rejects scan with malformed node_id ${JSON.stringify(badNodeId)}`, async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'bad-scan-node', + target_type: 'system', + node_id: badNodeId, + action: 'scan', + cron_expression: '0 0 * * *', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/valid node_id|node_id/); + }); + } + + for (const badNodeId of [true, '1.5', 0]) { + it(`rejects fleet update with malformed node_id ${JSON.stringify(badNodeId)}`, async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'bad-fleet-update-node', + target_type: 'fleet', + node_id: badNodeId, + action: 'update', + cron_expression: '0 0 * * *', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/valid node_id|node_id/); + }); + } + it('rejects scheduled scans on remote nodes', async () => { const remoteNodeId = DatabaseService.getInstance().addNode({ name: 'remote-scan-node', @@ -236,6 +264,45 @@ describe('POST /api/scheduled-tasks', () => { expect(res.body.error).toMatch(/local node/i); }); + it('rejects prune without node_id', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'cleanup', target_type: 'system', action: 'prune', cron_expression: '0 4 * * *', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Prune action requires node_id/); + }); + + it('rejects scheduled prunes on remote nodes', async () => { + const remoteNodeId = DatabaseService.getInstance().addNode({ + name: 'remote-prune-node', + type: 'remote', + api_url: 'http://remote.local:1852', + api_token: 'token', + compose_dir: '/srv/compose', + is_default: false, + }); + + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'remote-prune', + target_type: 'system', + node_id: remoteNodeId, + action: 'prune', + cron_expression: '0 4 * * *', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/local node/i); + }); + + it('creates a prune task on a local node', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'local-prune', target_type: 'system', node_id: 1, action: 'prune', + cron_expression: '0 4 * * *', enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.action).toBe('prune'); + expect(res.body.node_id).toBe(1); + }); + it('rejects target_services with wrong action', async () => { const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ ...basePayload, action: 'update', target_services: ['web'], @@ -528,6 +595,48 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => { expect(res.body.error).toMatch(/node_id/); }); + it('rejects updates that clear node_id on a prune task', async () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + const pruneId = db.createScheduledTask({ + name: 'prune', target_type: 'system', target_id: null, node_id: 1, action: 'prune', + cron_expression: '0 4 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + }); + + const res = await request(app) + .put(`/api/scheduled-tasks/${pruneId}`) + .set('Cookie', adminCookie) + .send({ node_id: null }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Prune action requires node_id/); + }); + + it('rejects updates that point a prune task at a remote node', async () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + const pruneId = db.createScheduledTask({ + name: 'prune', target_type: 'system', target_id: null, node_id: 1, action: 'prune', + cron_expression: '0 4 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now, + last_run_at: null, next_run_at: null, last_status: null, last_error: null, + prune_targets: null, target_services: null, prune_label_filter: null, + }); + const remoteNodeId = db.addNode({ + name: 'remote-prune-update-node', type: 'remote', api_url: 'http://remote.local:1852', + api_token: 'token', compose_dir: '/srv/compose', is_default: false, + }); + + const res = await request(app) + .put(`/api/scheduled-tasks/${pruneId}`) + .set('Cookie', adminCookie) + .send({ node_id: remoteNodeId }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/local node/i); + }); + it('rejects updates that clear target_type', async () => { const res = await request(app) .put(`/api/scheduled-tasks/${taskId}`) @@ -547,6 +656,45 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => { expect(res.status).toBe(400); expect(res.body.error).toMatch(/Invalid action/); }); + + it('clears stale stack and service fields when changing a task to a fleet snapshot', async () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + const restartId = db.createScheduledTask({ + name: 'restart', + target_type: 'stack', + target_id: 'web', + node_id: 1, + action: 'restart', + cron_expression: '0 3 * * *', + enabled: 1, + created_by: 'admin', + created_at: now, + updated_at: now, + last_run_at: null, + next_run_at: null, + last_status: null, + last_error: null, + prune_targets: null, + target_services: JSON.stringify(['api']), + prune_label_filter: null, + delete_after_run: 0, + }); + + const res = await request(app) + .put(`/api/scheduled-tasks/${restartId}`) + .set('Cookie', adminCookie) + .send({ action: 'snapshot', target_type: 'fleet' }); + + expect(res.status).toBe(200); + expect(res.body.action).toBe('snapshot'); + expect(res.body.target_type).toBe('fleet'); + expect(res.body.target_id).toBeNull(); + expect(res.body.node_id).toBeNull(); + expect(res.body.target_services).toBeNull(); + expect(res.body.prune_targets).toBeNull(); + expect(res.body.prune_label_filter).toBeNull(); + }); }); describe('scheduled-tasks state-invalidate broadcast', () => { diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 0fe6a09a..041a583e 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -594,6 +594,32 @@ describe('SchedulerService - executePrune', () => { expect(mockPruneSystem).toHaveBeenCalledWith('containers', 'env=staging'); }); + + it('fails scheduled prune tasks that target remote nodes before pruning', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 73, + name: 'remote-prune', + action: 'prune', + cron_expression: '0 3 * * *', + enabled: true, + node_id: 2, + created_by: 'admin', + last_status: null, + }); + mockGetNode.mockReturnValue({ id: 2, name: 'remote', type: 'remote', status: 'online' }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(73); + + expect(mockPruneSystem).not.toHaveBeenCalled(); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + status: 'failure', + error: expect.stringMatching(/local node/i), + }), + ); + }); }); // ── executeUpdate ────────────────────────────────────────────────────── diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index 7da017fe..bdf1b771 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -6,6 +6,7 @@ import { VALID_ACTIONS, INVALID_ACTION_MESSAGE, validateActionTarget, + getScheduledActionDefinition, type TargetType, type BackendScheduledAction, } from '../services/scheduledActionRegistry'; @@ -35,6 +36,31 @@ function broadcastScheduledTasksChanged(): void { const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const; const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.'; +function parsePositiveNodeId(nodeId: unknown): number | null { + if (typeof nodeId !== 'number' && typeof nodeId !== 'string') return null; + if (typeof nodeId === 'string' && nodeId.trim().length === 0) return null; + const parsedNodeId = Number(nodeId); + return Number.isInteger(parsedNodeId) && parsedNodeId > 0 ? parsedNodeId : null; +} + +function actionRequiresNode(action: BackendScheduledAction, targetType: TargetType): boolean { + if (targetType === 'stack') return true; + return getScheduledActionDefinition(action)?.requiresNode === true; +} + +function nodeRequirementLabel(action: BackendScheduledAction, targetType: TargetType): string { + if (action === 'scan') return 'Scan'; + if (action === 'prune') return 'Prune'; + if (action === 'update' && targetType === 'fleet') return 'Fleet update'; + return action; +} + +function localNodeRequirementLabel(action: BackendScheduledAction): string { + if (action === 'scan') return 'Scheduled vulnerability scans'; + if (action === 'prune') return 'Scheduled prunes'; + return `${action} tasks`; +} + function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null { if (targetType !== 'stack') return null; @@ -46,23 +72,38 @@ function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: return 'Stack target_id must be a valid stack name.'; } - const parsedNodeId = Number(nodeId); - if (!Number.isInteger(parsedNodeId) || parsedNodeId <= 0) { + if (parsePositiveNodeId(nodeId) === null) { return 'Stack operations require a valid node_id.'; } return null; } -function validateScanNode(nodeId: unknown): string | null { - if (nodeId == null) return 'Scan action requires node_id.'; - const parsedNodeId = Number(nodeId); - if (!Number.isFinite(parsedNodeId)) return 'Scan action requires a valid node_id.'; - const node = DatabaseService.getInstance().getNode(parsedNodeId); - if (!node) return 'Scheduled vulnerability scans require an existing local node.'; - if (node?.type === 'remote') { - return 'Scheduled vulnerability scans currently require a local node.'; +/** + * Shared guard for non-stack actions that require a node. Stack actions use + * validateStackTarget because they also require target_id. + */ +function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null { + if (targetType === 'stack') return null; + const def = getScheduledActionDefinition(action); + if (!def?.requiresNode) return null; + + const labelSingular = nodeRequirementLabel(action, targetType); + const labelPlural = localNodeRequirementLabel(action); + + if (nodeId == null) { + return action === 'update' && targetType === 'fleet' + ? ERR_FLEET_NODE_REQUIRED + : `${labelSingular} action requires node_id.`; } + + const parsedNodeId = parsePositiveNodeId(nodeId); + if (parsedNodeId === null) return `${labelSingular} action requires a valid node_id.`; + if (def.nodeScope !== 'local') return null; + + const node = DatabaseService.getInstance().getNode(parsedNodeId); + if (!node) return `${labelPlural} require an existing local node.`; + if (node.type === 'remote') return `${labelPlural} currently require a local node.`; return null; } @@ -153,16 +194,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { const targetErr = validateActionTarget(action, target_type); if (targetErr) { res.status(400).json({ error: targetErr }); return; } - if (action === 'scan' && !node_id) { - res.status(400).json({ error: 'Scan action requires node_id.' }); return; - } - if (action === 'scan') { - const nodeErr = validateScanNode(node_id); - if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } - } - if (action === 'update' && target_type === 'fleet' && !node_id) { - res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return; - } + const nodeErr = validateActionNode(action, target_type, node_id); + if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } const stackTargetErr = validateStackTarget(target_type, target_id, node_id); if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; } @@ -177,12 +210,14 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { const scheduler = SchedulerService.getInstance(); const now = Date.now(); const nextRun = (enabled !== false) ? scheduler.calculateNextRun(cron_expression) : null; + const normalizedTargetId = target_type === 'stack' ? target_id : null; + const normalizedNodeId = actionRequiresNode(action, target_type) ? parsePositiveNodeId(node_id) : null; const id = DatabaseService.getInstance().createScheduledTask({ name: name.trim(), target_type, - target_id: target_id || null, - node_id: node_id != null ? Number(node_id) : null, + target_id: normalizedTargetId, + node_id: normalizedNodeId, action, cron_expression, enabled: enabled !== false ? 1 : 0, @@ -193,9 +228,9 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { next_run_at: nextRun, last_status: null, last_error: null, - prune_targets: prune_targets ? JSON.stringify(prune_targets) : null, - target_services: target_services ? JSON.stringify(target_services) : null, - prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null, + prune_targets: action === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null, + target_services: action === 'restart' && target_type === 'stack' && target_services ? JSON.stringify(target_services) : null, + prune_label_filter: action === 'prune' && prune_label_filter ? prune_label_filter.trim() : null, delete_after_run: delete_after_run ? 1 : 0, }); @@ -244,20 +279,17 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const finalAction = (action ?? existing.action) as BackendScheduledAction; const finalTargetType = (target_type ?? existing.target_type) as TargetType; - const finalTargetId = target_id !== undefined ? target_id : existing.target_id; - const finalNodeId = node_id !== undefined ? node_id : existing.node_id; + const finalTargetId = finalTargetType === 'stack' + ? (target_id !== undefined ? target_id : existing.target_id) + : null; + const finalNodeId = actionRequiresNode(finalAction, finalTargetType) + ? (node_id !== undefined ? node_id : existing.node_id) + : null; const targetErr = validateActionTarget(finalAction, finalTargetType); if (targetErr) { res.status(400).json({ error: targetErr }); return; } - if (finalAction === 'scan') { - const nodeErr = validateScanNode(finalNodeId); - if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } - } - if (finalAction === 'update' && finalTargetType === 'fleet') { - if (!finalNodeId) { - res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return; - } - } + const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId); + if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId); if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; } @@ -274,15 +306,31 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const updates: Record = { updated_at: Date.now() }; if (name !== undefined) updates.name = typeof name === 'string' ? name.trim() : name; - if (target_type !== undefined) updates.target_type = target_type; - if (target_id !== undefined) updates.target_id = target_id || null; - if (node_id !== undefined) updates.node_id = node_id != null ? Number(node_id) : null; - if (action !== undefined) updates.action = action; + if (target_type !== undefined) updates.target_type = finalTargetType; + if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null; + if (node_id !== undefined || !actionRequiresNode(finalAction, finalTargetType)) { + updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null; + } + if (action !== undefined) updates.action = finalAction; if (cron_expression !== undefined) updates.cron_expression = cron_expression; if (enabled !== undefined) updates.enabled = enabled ? 1 : 0; - if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null; - if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null; - if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null; + if (prune_targets !== undefined) { + updates.prune_targets = finalAction === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null; + } else if (finalAction !== 'prune') { + updates.prune_targets = null; + } + if (target_services !== undefined) { + updates.target_services = finalAction === 'restart' && finalTargetType === 'stack' && target_services + ? JSON.stringify(target_services) + : null; + } else if (finalAction !== 'restart' || finalTargetType !== 'stack') { + updates.target_services = null; + } + if (prune_label_filter !== undefined) { + updates.prune_label_filter = finalAction === 'prune' && prune_label_filter ? prune_label_filter.trim() : null; + } else if (finalAction !== 'prune') { + updates.prune_label_filter = null; + } if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0; const finalCron = cron_expression || existing.cron_expression; diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 49e30622..fc5ef71d 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -986,22 +986,39 @@ export class FileSystemService { // credit the barrier, which it does not through the helper. const baseResolved = path.resolve(this.baseDir); const checksums: Record = {}; + const writeManagedBackupFile = async (file: string, src: string): Promise => { + let buf: Buffer; + try { + buf = await fsPromises.readFile(src); + } catch (e: unknown) { + const code = (e as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT') { + console.warn(`[FileSystemService] Could not read ${file} for backup:`, (e as Error).message); + } + return; + } + + const dest = path.join(backupDir, file); + try { + await fsPromises.writeFile(dest, buf); + } catch (e: unknown) { + try { + await fsPromises.unlink(dest); + } catch { + // Best-effort cleanup only. The write failure below is the actionable error. + } + throw new Error(`Could not write backup ${file}: ${(e as Error).message}`, { cause: e }); + } + checksums[file] = sha256HexBuffer(buf); + }; + const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; for (const file of composeFiles) { const src = path.resolve(baseResolved, path.join(stackDir, file)); if (!src.startsWith(baseResolved + path.sep)) { throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); } - try { - const buf = await fsPromises.readFile(src); - await fsPromises.writeFile(path.join(backupDir, file), buf); - checksums[file] = sha256HexBuffer(buf); - } catch (e: unknown) { - const code = (e as NodeJS.ErrnoException)?.code; - if (code !== 'ENOENT') { - console.warn(`[FileSystemService] Could not back up ${file}:`, (e as Error).message); - } - } + await writeManagedBackupFile(file, src); } // Copy .env if it exists (same inline containment barrier as above). @@ -1009,16 +1026,7 @@ export class FileSystemService { if (!envSrc.startsWith(baseResolved + path.sep)) { throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); } - try { - const buf = await fsPromises.readFile(envSrc); - await fsPromises.writeFile(path.join(backupDir, '.env'), buf); - checksums['.env'] = sha256HexBuffer(buf); - } catch (e: unknown) { - const code = (e as NodeJS.ErrnoException)?.code; - if (code !== 'ENOENT') { - console.warn('[FileSystemService] Could not back up .env:', (e as Error).message); - } - } + await writeManagedBackupFile('.env', envSrc); // Write the integrity manifest before the timestamp marker, so a crash // between the two leaves the checksums present (a backup that restore can diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index ba4db381..ecb7e623 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -662,6 +662,9 @@ export class SchedulerService { if (task.node_id == null && isDebugEnabled()) { console.log(`[SchedulerService:debug] Prune task ${task.id}: no node_id specified, using default node ${nodeId}`); } + if (this.isRemoteNode(nodeId)) { + throw new Error('Scheduled prunes currently require a local node.'); + } const docker = DockerController.getInstance(nodeId); const allTargets = ['containers', 'images', 'networks', 'volumes'] as const; type PruneTarget = typeof allTargets[number]; diff --git a/backend/src/services/scheduledActionRegistry.ts b/backend/src/services/scheduledActionRegistry.ts index e5e71fdf..417a9959 100644 --- a/backend/src/services/scheduledActionRegistry.ts +++ b/backend/src/services/scheduledActionRegistry.ts @@ -13,10 +13,12 @@ export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const; export type TargetType = typeof VALID_TARGET_TYPES[number]; -interface BackendScheduledActionDefinition { +export interface BackendScheduledActionDefinition { readonly id: string; /** Target types this action accepts. `update` is the only multi-target action. */ readonly targetTypes: readonly TargetType[]; + readonly requiresNode: boolean; + readonly nodeScope?: 'local'; } /** @@ -24,15 +26,15 @@ interface BackendScheduledActionDefinition { * in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ..."). */ export const BACKEND_SCHEDULED_ACTIONS = [ - { id: 'restart', targetTypes: ['stack'] }, - { id: 'snapshot', targetTypes: ['fleet'] }, - { id: 'prune', targetTypes: ['system'] }, - { id: 'update', targetTypes: ['stack', 'fleet'] }, - { id: 'scan', targetTypes: ['system'] }, - { id: 'auto_backup', targetTypes: ['stack'] }, - { id: 'auto_stop', targetTypes: ['stack'] }, - { id: 'auto_down', targetTypes: ['stack'] }, - { id: 'auto_start', targetTypes: ['stack'] }, + { id: 'restart', targetTypes: ['stack'], requiresNode: true }, + { id: 'snapshot', targetTypes: ['fleet'], requiresNode: false }, + { id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, + { id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true }, + { id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, + { id: 'auto_backup', targetTypes: ['stack'], requiresNode: true }, + { id: 'auto_stop', targetTypes: ['stack'], requiresNode: true }, + { id: 'auto_down', targetTypes: ['stack'], requiresNode: true }, + { id: 'auto_start', targetTypes: ['stack'], requiresNode: true }, ] as const satisfies readonly BackendScheduledActionDefinition[]; export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id']; @@ -77,3 +79,7 @@ export function validateActionTarget(action: BackendScheduledAction, targetType: if (!def) return null; return def.targetTypes.includes(targetType) ? null : TARGET_MISMATCH_MESSAGE[action]; } + +export function getScheduledActionDefinition(action: BackendScheduledAction): BackendScheduledActionDefinition | undefined { + return ACTION_BY_ID.get(action); +} diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 031a4f6c..ee150d7e 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -62,8 +62,8 @@ The All tasks toggle swaps the lane track for a sortable table. | **Auto-update Stack** | A specific stack on a specific node | Checks each image in the stack for a newer tag and recreates the stack if any image has an update. See [Auto-Update Policies](/features/auto-update-policies) for the companion review board. | | **Auto-update All Stacks** | A specific node | Runs the auto-update check across every stack on the node. Pair with **Auto-update Stack** rows when you want different cadences for specific stacks. | | **Fleet Snapshot** | The whole fleet | Creates a versioned, fleet-wide snapshot of every node's compose files and `.env` files. See [Fleet Backups](/features/fleet-backups). | -| **System Prune** | The selected node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. | -| **Vulnerability Scan** | A specific node | Runs Trivy against every image on the node and persists the results. Requires Trivy to be installed on the target node ([Installing Trivy](/operations/trivy-setup)). | +| **System Prune** | A local node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. Runs on local nodes only. | +| **Vulnerability Scan** | A local node | Runs Trivy against every image on the node and persists the results. Requires Trivy to be installed on the target node ([Installing Trivy](/operations/trivy-setup)). Runs on local nodes only. | | **Backup Stack Files** | A specific stack on a specific node | Copies the stack's compose file and `.env` to `/backups//`. One slot per stack; each run overwrites the previous backup. For a versioned archive use a Fleet Snapshot instead. | | **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop`. Containers are stopped but preserved. Use for off-hours power saving when you want a fast restart later. | | **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down`. Containers are removed. Use to fully release resources when the stack is not needed for an extended period. | @@ -87,10 +87,11 @@ Common fields: Conditional fields per action: -- **Stack actions** (Restart Stack, Auto-update Stack, Backup Stack Files, Stop / Take Down / Start Stack) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services, so you can scope the restart to a subset instead of restarting the entire stack. +- **Stack actions** (Restart Stack, Auto-update Stack, Backup Stack Files, Stop / Take Down / Start Stack) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services on the selected node, so you can scope the restart to a subset instead of restarting the entire stack. - **Auto-update All Stacks** adds a **Node** combobox with the helper text "Every stack on the selected node will be checked and updated when new images are available." -- **Vulnerability Scan** adds a **Node** combobox with the helper text "Every image on the selected node will be scanned." -- **System Prune** adds a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label. +- **Vulnerability Scan** adds a **Node** combobox listing local nodes only, with the helper text "Every image on the selected node will be scanned. Scans run on local nodes only." +- **System Prune** adds a **Node** combobox listing local nodes only, then a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label. +- **Fleet Snapshot** shows a read-only **Scope: Entire fleet** summary instead of a Node or Stack picker, because it captures every node. The New scheduled task modal configured for a Restart Stack task. Name reads 'Nightly staging restart'. Action is Restart Stack. Node is Local. Stack is 'audit-mesh-prod'. A Services group with helper text '(leave empty for all)' shows two unchecked checkboxes labelled 'echo' and 'prober'. Cron Expression is '0 3 * * *' with the preview 'At 03:00 AM'. The Enabled toggle reads ON. The Delete after successful run checkbox is unchecked. Cancel and Create buttons sit at the bottom right. diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 99b8cb84..f74c63d8 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -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 = { 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' && ( +
+ +
+ Entire fleet +
+

Captures every node's compose and .env files. No node or stack to choose.

+
+ )} + {currentAction?.requiresNode && !currentAction.requiresStack && (
= {}): 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); // 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(); + + 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, + }); + }); + }); }); diff --git a/frontend/src/lib/__tests__/scheduledActions.test.ts b/frontend/src/lib/__tests__/scheduledActions.test.ts index 11db6d03..8e027767 100644 --- a/frontend/src/lib/__tests__/scheduledActions.test.ts +++ b/frontend/src/lib/__tests__/scheduledActions.test.ts @@ -38,6 +38,11 @@ describe('scheduledActions registry', () => { } }); + 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(); diff --git a/frontend/src/lib/scheduledActions.ts b/frontend/src/lib/scheduledActions.ts index 1c8e95fc..c33ffa62 100644 --- a/frontend/src/lib/scheduledActions.ts +++ b/frontend/src/lib/scheduledActions.ts @@ -37,6 +37,7 @@ export interface ScheduledActionDefinition { requiresNode: boolean; requiresStack: boolean; supportsServiceSelection: boolean; + nodeScope?: 'local'; helperText?: string; } @@ -46,8 +47,8 @@ export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [ { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false }, { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks', shortLabel: 'update', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every stack on the selected node will be checked and updated when new images are available.' }, { id: 'snapshot', backendAction: 'snapshot', label: 'Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false }, - { id: 'prune', backendAction: 'prune', label: 'System Prune', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false }, - { id: 'scan', backendAction: 'scan', label: 'Vulnerability Scan', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every image on the selected node will be scanned.' }, + { id: 'prune', backendAction: 'prune', label: 'System Prune', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Resources are pruned on the selected node. Prunes run on local nodes only.' }, + { id: 'scan', backendAction: 'scan', label: 'Vulnerability Scan', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Every image on the selected node will be scanned. Scans run on local nodes only.' }, { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false }, { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack (keep containers)', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false }, { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down (remove containers)', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false }, diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts index f4cae67c..23ec84ef 100644 --- a/frontend/src/types/scheduling.ts +++ b/frontend/src/types/scheduling.ts @@ -35,4 +35,5 @@ export interface TaskRun { export interface NodeOption { id: number; name: string; + type: 'local' | 'remote'; }