From c491d309c12a7b823915e40c1f340495076dec32 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 21 May 2026 16:42:41 -0400 Subject: [PATCH] feat(schedules): make every scheduled action available on Skipper (#1141) Collapse the Admiral carveout that restricted restart, prune, auto_backup, auto_stop, auto_down, and auto_start schedules to the Admiral variant. Scheduled Operations stays at Skipper+ (paid). The action picker now lists every supported operation for any paid admin, and the scheduler runner executes every action on either variant. --- .../__tests__/scheduled-tasks-routes.test.ts | 34 +++++++++++++++---- .../src/__tests__/scheduler-service.test.ts | 11 +++--- backend/src/middleware/tierGates.ts | 9 ----- backend/src/routes/scheduledTasks.ts | 17 ++-------- backend/src/services/SchedulerService.ts | 8 +---- docs/features/licensing.mdx | 5 ++- docs/features/scheduled-operations.mdx | 28 +++++++-------- .../components/ScheduledOperationsView.tsx | 20 ++--------- 8 files changed, 55 insertions(+), 77 deletions(-) diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index 5623e7d5..a344bffb 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -14,13 +14,14 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic let adminCookie: string; let viewerCookie: string; let variantSpy: ReturnType; +let tierSpy: ReturnType; beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); variantSpy = vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); @@ -90,7 +91,7 @@ describe('GET /api/scheduled-tasks', () => { expect(Array.isArray(res.body[0].next_runs)).toBe(true); }); - it('shows scan and snapshot tasks to Skipper users', async () => { + it('shows every action to Skipper users', async () => { const db = DatabaseService.getInstance(); const now = Date.now(); db.createScheduledTask({ @@ -154,7 +155,7 @@ describe('GET /api/scheduled-tasks', () => { const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie); expect(res.status).toBe(200); - expect(res.body.map((t: { action: string }) => t.action).sort()).toEqual(['scan', 'snapshot']); + expect(res.body.map((t: { action: string }) => t.action).sort()).toEqual(['prune', 'scan', 'snapshot']); }); }); @@ -441,16 +442,35 @@ describe('POST /api/scheduled-tasks - Skipper tier gating', () => { expect(res.body.action).toBe('snapshot'); }); - for (const action of ['restart', 'prune', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) { - it(`rejects Skipper admins from creating ${action} tasks with 403`, async () => { + for (const action of ['restart', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) { + it(`allows Skipper admins to create ${action} tasks`, async () => { const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ name: `skipper-${action}`, target_type: 'stack', target_id: 'my-stack', node_id: 1, action, cron_expression: '0 3 * * *', enabled: true, }); - expect(res.status).toBe(403); - expect(res.body.code).toBe('ADMIRAL_REQUIRED'); + expect(res.status).toBe(201); + expect(res.body.action).toBe(action); }); } + + it('allows Skipper admins to create prune tasks', async () => { + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'skipper-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'); + }); + + it('rejects Community admins from creating any scheduled task with 403', async () => { + tierSpy.mockReturnValueOnce('community'); + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'community-update', target_type: 'stack', target_id: 'my-stack', node_id: 1, + action: 'update', cron_expression: '0 3 * * *', enabled: true, + }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); }); describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => { diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 9524f836..bb68e8a0 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -292,15 +292,17 @@ describe('SchedulerService - license gating', () => { expect(mockCreateScheduledTaskRun).toHaveBeenCalled(); }); - it('skips non-update/scan/snapshot tasks for non-admiral pro', async () => { + it('executes restart tasks for non-admiral pro (Skipper)', async () => { mockGetTier.mockReturnValue('paid'); mockGetVariant.mockReturnValue('individual'); mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]); + mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]); const svc = SchedulerService.getInstance(); await (svc as any).tick(); - expect(mockCreateScheduledTaskRun).not.toHaveBeenCalled(); + await new Promise(r => setTimeout(r, 50)); + expect(mockCreateScheduledTaskRun).toHaveBeenCalled(); }); it('allows snapshot tasks for non-admiral pro (Skipper)', async () => { @@ -1525,7 +1527,7 @@ describe('SchedulerService - lifecycle actions', () => { expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' })); }); - it('non-admiral paid tier skips lifecycle actions', async () => { + it('non-admiral paid tier executes lifecycle actions', async () => { mockGetTier.mockReturnValue('paid'); mockGetVariant.mockReturnValue('standard'); mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop')); @@ -1534,7 +1536,8 @@ describe('SchedulerService - lifecycle actions', () => { const svc = SchedulerService.getInstance(); await (svc as any).tick(); - expect(mockRunCommand).not.toHaveBeenCalled(); + await new Promise(r => setTimeout(r, 50)); + expect(mockRunCommand).toHaveBeenCalled(); }); }); diff --git a/backend/src/middleware/tierGates.ts b/backend/src/middleware/tierGates.ts index 478a86a5..f4e7d6fd 100644 --- a/backend/src/middleware/tierGates.ts +++ b/backend/src/middleware/tierGates.ts @@ -59,15 +59,6 @@ export const requireNodeProxy = (req: Request, res: Response): boolean => { return true; }; -/** Scheduled task actions a Skipper-tier license may create and view. All other actions are Admiral-only. */ -export const SKIPPER_SCHEDULED_ACTIONS: ReadonlySet = new Set(['update', 'scan', 'snapshot']); - -/** Tier gate for scheduled tasks: SKIPPER_SCHEDULED_ACTIONS require Skipper+, everything else requires Admiral. */ -export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => { - if (SKIPPER_SCHEDULED_ACTIONS.has(action)) return requirePaid(req, res); - return requireAdmiral(req, res); -}; - /** * Tier gate for SSO providers. The split is by delivery (turnkey vs self-configured), not by * protocol: Custom OIDC stays free so self-hosters can wire any OIDC IdP (Authelia, Keycloak, diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index c602ece5..e9fcc3a5 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -1,9 +1,8 @@ import { Router, type Request, type Response } from 'express'; import { CronExpressionParser } from 'cron-parser'; import { DatabaseService, type ScheduledTask } from '../services/DatabaseService'; -import { LicenseService } from '../services/LicenseService'; import { SchedulerService } from '../services/SchedulerService'; -import { requirePaid, requireAdmin, requireScheduledTaskTier, SKIPPER_SCHEDULED_ACTIONS } from '../middleware/tierGates'; +import { requirePaid, requireAdmin } from '../middleware/tierGates'; import { escapeCsvField } from '../utils/csv'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; @@ -109,11 +108,6 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; try { let tasks = DatabaseService.getInstance().getScheduledTasks(); - // Skipper users see v1 fleet-maintenance tasks; Admiral sees all. - const ls = LicenseService.getInstance(); - if (ls.getVariant() !== 'admiral') { - tasks = tasks.filter(t => SKIPPER_SCHEDULED_ACTIONS.has(t.action)); - } // Split Auto-Update and Scheduled Operations into distinct views. const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined; const excludeAction = typeof req.query.exclude_action === 'string' ? req.query.exclude_action : undefined; @@ -142,6 +136,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => { scheduledTasksRouter.post('/', (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; try { const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body; @@ -154,7 +149,6 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { if (!(VALID_ACTIONS as readonly string[]).includes(action)) { res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, scan, auto_backup, auto_stop, auto_down, or auto_start.' }); return; } - if (!requireScheduledTaskTier(action, req, res)) return; const targetErr = validateActionTarget(action, target_type); if (targetErr) { res.status(400).json({ error: targetErr }); return; } @@ -222,7 +216,6 @@ scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => { if (id === null) return; const task = DatabaseService.getInstance().getScheduledTask(id); if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(task.action, req, res)) return; res.json(task); } catch (error) { console.error('[ScheduledTasks] Get error:', error); @@ -240,7 +233,6 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body; @@ -322,7 +314,6 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; db.deleteScheduledTask(id); console.log(`[ScheduledTasks] Deleted task id=${id}`); @@ -343,7 +334,6 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; const newEnabled = existing.enabled ? 0 : 1; const nextRun = newEnabled ? SchedulerService.getInstance().calculateNextRun(existing.cron_expression) : null; @@ -373,7 +363,6 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; const scheduler = SchedulerService.getInstance(); if (scheduler.isTaskRunning(id)) { @@ -404,7 +393,6 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void const db = DatabaseService.getInstance(); const task = db.getScheduledTask(id); if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(task.action, req, res)) return; const runs = db.getAllScheduledTaskRuns(id); @@ -440,7 +428,6 @@ scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => { const db = DatabaseService.getInstance(); const existing = db.getScheduledTask(id); if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; } - if (!requireScheduledTaskTier(existing.action, req, res)) return; const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index fb95d873..a543d901 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -195,9 +195,7 @@ export class SchedulerService { await this.maybeRedetectTrivy(); const ls = LicenseService.getInstance(); - const isPaid = ls.getTier() === 'paid'; - const isAdmiral = isPaid && ls.getVariant() === 'admiral'; - if (!isPaid) return; + if (ls.getTier() !== 'paid') return; const now = Date.now(); const dueTasks = db.getDueScheduledTasks(now); @@ -211,10 +209,6 @@ export class SchedulerService { db.deleteOldScans(90 * 24 * 60 * 60 * 1000); for (const task of dueTasks) { - if (!isAdmiral && task.action !== 'update' && task.action !== 'scan' && task.action !== 'snapshot') { - if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: action "${task.action}" requires Admiral tier`); - continue; - } if (this.runningTasks.has(task.id)) { if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: already running`); continue; diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 911b8995..3b38823c 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -43,7 +43,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Atomic deployments with rollback - Auto-update policies for stack images - Auto-heal policies -- Scheduled tasks for stack updates, vulnerability scans, and fleet snapshots +- Scheduled operations across the full action catalog (lifecycle, updates, scans, snapshots, prune) - Scan policies with `block_on_deploy` deploy enforcement, SBOM (SPDX, CycloneDX), and SARIF export - Bulk actions on a label (deploy, stop, or restart every stack tagged with it) - Remote OTA node updates from the control instance @@ -64,11 +64,10 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Sencho Mesh (cross-node container networking) - Sencho Cloud Backup - Auto-update of the managed Trivy binary -- All other scheduled task actions (restart, prune, and more) ## Free trial -Sencho offers a **14-day Admiral trial** so you can evaluate the flagship features (Host Console, Sencho Mesh, scheduled operations, LDAP / Active Directory, audit log, unlimited accounts) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans; the Founder Lifetime plan does not include a trial. +Sencho offers a **14-day Admiral trial** so you can evaluate the flagship features (Host Console, Sencho Mesh, LDAP / Active Directory, audit log, unlimited accounts) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans; the Founder Lifetime plan does not include a trial. To start a trial: diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index bd89cb77..b611b832 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -6,7 +6,7 @@ description: Automate stack lifecycle, image updates, vulnerability scans, fleet Schedules is a unified surface for every recurring maintenance operation Sencho knows how to run: stack restarts, per-node and fleet-wide image updates, lifecycle events (stop, take down, start, backup), system prunes, and vulnerability scans. The default view is a next-24-hour Timeline of upcoming runs across five lanes; an All tasks table view lists every schedule regardless of when it next fires. - Available to admins on Skipper and Admiral. Skipper unlocks **Auto-update Stack**, **Auto-update All Stacks**, **Vulnerability Scan**, and **Fleet Snapshot**. The remaining actions (Restart Stack, System Prune, Backup Stack Files, Stop / Take Down / Start Stack) require Admiral. The action picker hides operations your tier cannot run. + Available to admins on Skipper and Admiral. @@ -56,22 +56,22 @@ The All tasks toggle swaps the lane track for a sortable table. ## Supported actions -| Action | Tier | Target | What it does | -|---|---|---|---| -| **Restart Stack** | Admiral | A specific stack (optionally specific services) on a specific node | Restarts all or selected containers in the stack. | -| **Auto-update Stack** | Skipper | 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 Readiness](/features/auto-update-policies) for the companion review board. | -| **Auto-update All Stacks** | Skipper | A specific node | Runs the auto-update check across every stack on the node that has auto-updates enabled. Stacks with auto-updates turned off are skipped. | -| **Fleet Snapshot** | Skipper | 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** | Admiral | The selected node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. | -| **Vulnerability Scan** | Skipper | 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)). | -| **Backup Stack Files** | Admiral | 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** | Admiral | 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** | Admiral | 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. | -| **Start Stack** | Admiral | A specific stack on a specific node | Runs `docker compose up -d`. Works for both stopped and removed containers: if they exist they are started, if not they are created from the compose file. | +| Action | Target | What it does | +|---|---|---| +| **Restart Stack** | A specific stack (optionally specific services) on a specific node | Restarts all or selected containers in the stack. | +| **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 Readiness](/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 that has auto-updates enabled. Stacks with auto-updates turned off are skipped. | +| **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)). | +| **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. | +| **Start Stack** | A specific stack on a specific node | Runs `docker compose up -d`. Works for both stopped and removed containers: if they exist they are started, if not they are created from the compose file. | ## Creating a scheduled task -Click **New Schedule** in the header. The form opens in a centered modal. The Action picker only shows operations your tier can run, so Skipper admins see four options and Admiral admins see all ten. +Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation. The New scheduled task modal with the Action combobox expanded. The dropdown lists Restart Stack, Auto-update Stack, Auto-update All Stacks, Fleet Snapshot, System Prune, and Vulnerability Scan as the first six entries. Below the picker, partly visible, sit a Services row with 'echo' and 'prober' checkboxes, the Cron Expression input, the Enabled toggle, and the Delete after successful run checkbox. diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 3f1b7b26..6d51c089 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -13,22 +13,11 @@ import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, Che import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { Combobox } from '@/components/ui/combobox'; -import { useLicense } from '@/context/LicenseContext'; import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; import { getCronDescription, formatTimestamp } from '@/lib/scheduling'; const UPDATE_FLEET_ACTION = 'update-fleet' as const; -// Mirrors backend `SKIPPER_SCHEDULED_ACTIONS` in tierGates.ts. Picker options -// whose backend action falls outside this set are Admiral-only and hidden from -// Skipper users so the Combobox never offers a choice the API will reject. -const SKIPPER_BACKEND_ACTIONS: ReadonlySet = new Set(['update', 'scan', 'snapshot']); - -function isActionAllowedForVariant(option: { value: string; backendAction?: string }, variant: string | null | undefined): boolean { - if (variant === 'admiral') return true; - return SKIPPER_BACKEND_ACTIONS.has(option.backendAction ?? option.value); -} - const ACTION_OPTIONS: Array<{ value: string; label: string; @@ -86,11 +75,6 @@ interface ScheduledOperationsViewProps { } export default function ScheduledOperationsView({ filterNodeId, onClearFilter, prefill, onPrefillConsumed }: ScheduledOperationsViewProps) { - const { license } = useLicense(); - const visibleActionOptions = useMemo( - () => ACTION_OPTIONS.filter(o => isActionAllowedForVariant(o, license?.variant)), - [license?.variant] - ); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [view, setView] = useState<'timeline' | 'table'>('timeline'); @@ -225,7 +209,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : ''); setEditingTask(null); setFormName(''); - setFormAction(visibleActionOptions[0]?.value ?? 'restart'); + setFormAction(ACTION_OPTIONS[0]?.value ?? 'restart'); setFormTargetId(prefillData?.stackName ?? ''); setFormNodeId(nodeId); setFormCron('0 3 * * *'); @@ -692,7 +676,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
({ value: o.value, label: o.label }))} + options={ACTION_OPTIONS.map(o => ({ value: o.value, label: o.label }))} value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }} placeholder="Select action..."