feat(scheduler): add helper text and risk badges to scheduled action picker (#1449)

* feat(scheduler): add helper text and risk badges to scheduled action picker

Add a concise helper text and risk level badge to every scheduled action
in the create/edit modal. The six risk levels (Safe, Read-only, Interruptive,
Runtime change, Removes containers, Destructive) map to the four existing
design-system tones and render as a small dot+label chip next to the helper
text, following the same pattern as SeverityBadge.

Fix an ambiguous mobile label: update + target_type: fleet now resolves
through resolveTaskAction and renders 'update node stacks' instead of the
misleading 'update fleet'.

Add exact helper-text and risk-level assertions for all 10 actions, plus
component tests for default modal state, action-switch scenarios, and
mobile update+fleet rendering.

* docs: update stale scheduled-operations alt text for changed helper text
This commit is contained in:
Anso
2026-06-25 02:37:12 -04:00
committed by GitHub
parent 79f840ab6e
commit 7982251dc6
7 changed files with 240 additions and 27 deletions
+4 -4
View File
@@ -80,7 +80,7 @@ Click **New Schedule** in the header. The form opens in a centered modal. The Ac
Common fields:
- **Name.** A descriptive label.
- **Action.** Pick the operation. The form below changes based on your selection.
- **Action.** Pick the operation. Once selected, the form shows a risk badge and a one-line helper text below the picker so you can see the blast radius at a glance. The six risk levels are Safe (informational, no risk), Read-only (observation only), Interruptive (temporarily disrupts running state), Runtime change (changes running state, not destructive), Removes containers (tears down containers), and Destructive (irreversibly deletes resources).
- **Cron Expression.** A standard 5-field cron. A human-readable preview appears below the input as you type (`At 03:00 AM`, `At 04:00 AM, only on Sunday`, and so on). See [Cron expression reference](#cron-expression-reference).
- **Enabled.** Toggle the task on or off without deleting it.
- **Delete after successful run.** When enabled, the task removes itself from the schedule after its first successful execution. Failures keep the task so you can inspect the error and retry. See [Delete after successful run](#delete-after-successful-run).
@@ -88,8 +88,8 @@ Common fields:
Conditional fields per action:
- **Stack actions** (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Auto-update Stack, Stop Stack, Take Stack Down) 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 on Node** adds a **Node** combobox with the helper text "Every stack on the selected node will be checked and updated when new images are available."
- **Scan Node Images** 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."
- **Auto-update All Stacks on Node** adds a **Node** combobox. The helper text "Checks every stack on the selected node and updates stacks with newer images" appears above, next to the Runtime change badge.
- **Scan Node Images** adds a **Node** combobox listing local nodes only. The helper text "Runs Trivy against images on the selected local node and records the findings" and Read-only badge appear above.
- **Prune Node Resources** 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.
- **Create Fleet Snapshot** shows a read-only **Scope: Entire fleet** summary instead of a Node or Stack picker, because it captures every node.
@@ -110,7 +110,7 @@ A Scan Node Images task runs Trivy against every image on the selected node and
When a scheduled scan finishes, Sencho dispatches a completion notification. The severity reflects the outcome (info on a clean run, warning when findings are present); the category is `scan_finding`. The full message format and how it surfaces in the bell is documented in [Alerts & Notifications · Vulnerability scanning](/features/alerts-notifications#vulnerability-scanning).
<Frame>
<img src="/images/scheduled-operations/create-scan.png" alt="The New scheduled task modal configured for a Scan Node Images. Name reads 'Nightly vulnerability scan'. Action is Scan Node Images. Node is Local with helper text explaining every image on the selected node will be scanned and scans run on local nodes only. Cron Expression is '0 3 * * *' with the preview 'At 03:00 AM'. The Enabled toggle reads ON. Cancel and Create buttons sit at the bottom right." />
<img src="/images/scheduled-operations/create-scan.png" alt="The New scheduled task modal configured for a Scan Node Images task. Name reads 'Nightly vulnerability scan'. Action is Scan Node Images. Node is Local. A Read-only risk badge and the helper text 'Runs Trivy against images on the selected local node and records the findings.' appear below the action picker. Cron Expression is '0 3 * * *' with the preview 'At 03:00 AM'. The Enabled toggle reads ON. Cancel and Create buttons sit at the bottom right." />
</Frame>
### Prune label filter
@@ -15,12 +15,16 @@ import { apiFetch, fetchForNode } from '@/lib/api';
import { Combobox } from '@/components/ui/combobox';
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
import { getCronDescription, getCronFieldError, formatTimestamp } from '@/lib/scheduling';
import { cn } from '@/lib/utils';
import {
SCHEDULED_ACTIONS,
SCHEDULED_ACTION_CATEGORIES,
getActionById,
resolveTaskAction,
DEFAULT_SCHEDULED_ACTION_ID,
RISK_BADGE_CLASSES,
RISK_DOT_CLASSES,
RISK_LABEL,
} from '@/lib/scheduledActions';
const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'];
@@ -685,6 +689,18 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}
placeholder="Select action..."
/>
{currentAction && (
<div className="flex items-start gap-2">
<span className={cn(
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium shrink-0 mt-0.5',
RISK_BADGE_CLASSES[currentAction.riskLevel],
)}>
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', RISK_DOT_CLASSES[currentAction.riskLevel])} />
{RISK_LABEL[currentAction.riskLevel]}
</span>
<p className="text-xs text-muted-foreground">{currentAction.helperText}</p>
</div>
)}
</div>
{currentAction?.requiresStack && (
@@ -750,9 +766,6 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
onValueChange={setFormNodeId}
placeholder="Select node..."
/>
{currentAction.helperText && (
<p className="text-xs text-muted-foreground">{currentAction.helperText}</p>
)}
</div>
)}
@@ -454,4 +454,44 @@ describe('ScheduledOperationsView', () => {
});
});
});
describe('risk badge and helper text', () => {
it('shows Interruptive badge and helper for the default action Restart Stack', async () => {
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
expect(screen.getByText('Interruptive')).toBeInTheDocument();
expect(screen.getByText(
'Restarts containers in place. Running services are stopped and started again on the same configuration.',
)).toBeInTheDocument();
});
it('shows Safe badge and helper for Create Fleet Snapshot', async () => {
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
// Switch action to Create Fleet Snapshot (a non-node, non-stack action).
await userEvent.click(screen.getAllByRole('combobox')[0]);
await userEvent.click(await screen.findByRole('button', { name: 'Create Fleet Snapshot' }));
expect(screen.getByText('Safe')).toBeInTheDocument();
expect(screen.getByText(
'Creates a versioned snapshot of compose and env files across the fleet.',
)).toBeInTheDocument();
});
it('shows Destructive badge and helper for Prune Node Resources', async () => {
render(<ScheduledOperationsView />);
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
// Switch action to Prune Node Resources.
await userEvent.click(screen.getAllByRole('combobox')[0]);
await userEvent.click(await screen.findByRole('button', { name: 'Prune Node Resources' }));
expect(screen.getByText('Destructive')).toBeInTheDocument();
expect(screen.getByText(
'Removes unused Docker resources on the selected node. Be careful when pruning volumes.',
)).toBeInTheDocument();
});
});
});
@@ -2,19 +2,19 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import type { ScheduledTask } from '@/types/scheduling';
import { getActionById } from '@/lib/scheduledActions';
import { resolveTaskAction } from '@/lib/scheduledActions';
import { Masthead, SectionHead, StateDot, type Tone } from './mobile-ui';
interface MobileSchedulesProps {
headerActions: ReactNode;
}
function actionTone(action: ScheduledTask['action']): Tone {
return getActionById(action)?.tone ?? 'brand';
function actionTone(task: ScheduledTask): Tone {
return resolveTaskAction(task)?.tone ?? 'brand';
}
function actionShortLabel(action: ScheduledTask['action']): string {
return getActionById(action)?.shortLabel ?? action;
function actionShortLabel(task: ScheduledTask): string {
return resolveTaskAction(task)?.shortLabel ?? task.action;
}
function hhmm(ts: number): string {
@@ -44,7 +44,10 @@ function dayLabel(ts: number, now: number): string {
function targetLabel(task: ScheduledTask): string {
if (task.target_type === 'stack') return (task.target_id ?? task.name).replace(/\.(ya?ml)$/, '');
if (task.target_type === 'fleet') return 'fleet';
if (task.target_type === 'fleet') {
if (task.action === 'update') return 'stacks';
return 'fleet';
}
return task.target_type;
}
@@ -113,7 +116,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
state={next ? hhmm(next.runAt) : '--:--'}
stateTone="brand"
live={false}
meta={next ? `${relative(next.runAt, now)} · ${actionShortLabel(next.task.action)} ${targetLabel(next.task)}` : 'nothing scheduled'}
meta={next ? `${relative(next.runAt, now)} · ${actionShortLabel(next.task)} ${targetLabel(next.task)}` : 'nothing scheduled'}
right={headerActions}
/>
@@ -130,7 +133,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
upcoming.map((run, i) => {
const prevDay = i > 0 ? dayLabel(upcoming[i - 1].runAt, now) : null;
const day = dayLabel(run.runAt, now);
const tone = actionTone(run.task.action);
const tone = actionTone(run.task);
return (
<div key={`${run.task.id}-${run.runAt}`}>
{day !== prevDay ? <SectionHead>{day}</SectionHead> : null}
@@ -138,7 +141,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
<span className="w-[46px] shrink-0 font-mono tabular-nums text-[13px] text-stat-value">{hhmm(run.runAt)}</span>
<StateDot tone={tone} size={7} glow />
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-stat-subtitle">
<span className="text-stat-value">{actionShortLabel(run.task.action)}</span>{` ${targetLabel(run.task)}`}
<span className="text-stat-value">{actionShortLabel(run.task)}</span>{` ${targetLabel(run.task)}`}
</span>
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{relative(run.runAt, now)}</span>
</div>
@@ -64,4 +64,35 @@ describe('MobileSchedules', () => {
// renders with the destructive class only if the tone is wired through.
expect(container.querySelector('.bg-destructive')).toBeTruthy();
});
describe('update + fleet target', () => {
it('renders node-scoped copy, not fleet-ambiguous text', async () => {
mockedFetch.mockResolvedValue(jsonResponse([
makeTask({ id: 1, action: 'update', target_type: 'fleet', target_id: null, node_id: 2 }),
]));
render(<MobileSchedules headerActions={null} />);
const row = await screen.findByText('update node');
expect(row).toBeInTheDocument();
// The row must not contain the forbidden ambiguous copy.
expect(screen.queryByText('update fleet')).not.toBeInTheDocument();
expect(screen.queryByText('update all')).not.toBeInTheDocument();
// The target label for update+fleet renders 'stacks', not 'fleet'.
expect(screen.getByText('stacks')).toBeInTheDocument();
});
});
it('renders fleet target label for a snapshot task', async () => {
mockedFetch.mockResolvedValue(jsonResponse([
makeTask({ id: 1, action: 'snapshot', target_type: 'fleet', target_id: null, node_id: null }),
]));
render(<MobileSchedules headerActions={null} />);
// Snapshot IS fleet-wide; 'fleet' in the target label is correct.
expect(await screen.findByText('fleet')).toBeInTheDocument();
});
});
@@ -10,8 +10,13 @@ import {
DEFAULT_SCHEDULED_ACTION_ID,
getActionById,
resolveTaskAction,
RISK_LABEL,
RISK_TONE,
RISK_BADGE_CLASSES,
RISK_DOT_CLASSES,
type BackendAction,
type ScheduledActionCategory,
type ScheduledActionRiskLevel,
} from '../scheduledActions';
const BACKEND_ACTIONS: BackendAction[] = [
@@ -93,4 +98,79 @@ describe('scheduledActions registry', () => {
expect(fleetUpdate!.backendAction).toBe('update');
expect(fleetUpdate!.targetType).toBe('fleet');
});
describe('helperText', () => {
const expected: Record<string, string> = {
'auto_backup': 'Backs up compose and env files only. This does not back up application volumes.',
'auto_start': 'Creates containers if they do not exist, or starts existing stopped containers.',
'restart': 'Restarts containers in place. Running services are stopped and started again on the same configuration.',
'auto_stop': 'Stops containers but keeps them in place for a faster start later.',
'auto_down': 'Runs compose down. Containers are removed, but compose files remain on disk.',
'update': "Checks this stack's images and recreates the stack only when newer images are available.",
'update-fleet': 'Checks every stack on the selected node and updates stacks with newer images.',
'scan': 'Runs Trivy against images on the selected local node and records the findings.',
'prune': 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.',
'snapshot': 'Creates a versioned snapshot of compose and env files across the fleet.',
};
for (const [id, text] of Object.entries(expected)) {
it(`${id} helper text matches the specified wording`, () => {
expect(getActionById(id)?.helperText).toBe(text);
});
}
});
describe('riskLevel', () => {
const expected: Record<string, ScheduledActionRiskLevel> = {
'auto_backup': 'safe',
'auto_start': 'runtime-change',
'restart': 'interruptive',
'auto_stop': 'interruptive',
'auto_down': 'removes-containers',
'update': 'runtime-change',
'update-fleet': 'runtime-change',
'scan': 'read-only',
'prune': 'destructive',
'snapshot': 'safe',
};
for (const [id, level] of Object.entries(expected)) {
it(`${id} risk level is ${level}`, () => {
expect(getActionById(id)?.riskLevel).toBe(level);
});
}
it('every action has a RISK_LABEL entry', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(RISK_LABEL[def.riskLevel]).toBeTruthy();
}
});
});
describe('risk metadata maps', () => {
it('RISK_TONE maps every action risk level', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(RISK_TONE[def.riskLevel]).toBeTruthy();
}
});
it('RISK_BADGE_CLASSES maps every action risk level', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(RISK_BADGE_CLASSES[def.riskLevel]).toBeTruthy();
}
});
it('RISK_DOT_CLASSES maps every action risk level', () => {
for (const def of SCHEDULED_ACTIONS) {
expect(RISK_DOT_CLASSES[def.riskLevel]).toBeTruthy();
}
});
});
it('resolveTaskAction for update+fleet returns correct metadata', () => {
const def = resolveTaskAction({ action: 'update', target_type: 'fleet' });
expect(def?.id).toBe('update-fleet');
expect(def?.riskLevel).toBe('runtime-change');
expect(def?.helperText).toBe('Checks every stack on the selected node and updates stacks with newer images.');
});
});
+57 -11
View File
@@ -24,6 +24,49 @@ export type ScheduledActionId = BackendAction | 'update-fleet';
export type ScheduledActionCategory = 'lifecycle' | 'updates' | 'security' | 'maintenance' | 'backups';
export type ScheduledActionTone = 'success' | 'warning' | 'destructive' | 'brand';
/** Risk level assigned to each scheduled action, shown as a badge in the create/edit form. */
export type ScheduledActionRiskLevel = 'safe' | 'read-only' | 'interruptive' | 'runtime-change' | 'removes-containers' | 'destructive';
/** Human-readable label for each risk level. */
export const RISK_LABEL: Record<ScheduledActionRiskLevel, string> = {
'safe': 'Safe',
'read-only': 'Read-only',
'interruptive': 'Interruptive',
'runtime-change': 'Runtime change',
'removes-containers': 'Removes containers',
'destructive': 'Destructive',
};
/** Design-system tone for each risk level. */
export const RISK_TONE: Record<ScheduledActionRiskLevel, ScheduledActionTone> = {
'safe': 'success',
'read-only': 'brand',
'interruptive': 'warning',
'runtime-change': 'warning',
'removes-containers': 'destructive',
'destructive': 'destructive',
};
/** Chip border/background/text classes for each risk level. */
export const RISK_BADGE_CLASSES: Record<ScheduledActionRiskLevel, string> = {
'safe': 'border-success/25 bg-success/8 text-success',
'read-only': 'border-brand/25 bg-brand/8 text-brand',
'interruptive': 'border-warning/25 bg-warning/8 text-warning',
'runtime-change': 'border-warning/25 bg-warning/8 text-warning',
'removes-containers': 'border-destructive/25 bg-destructive/8 text-destructive',
'destructive': 'border-destructive/25 bg-destructive/8 text-destructive',
};
/** Leading dot fill class for each risk level. */
export const RISK_DOT_CLASSES: Record<ScheduledActionRiskLevel, string> = {
'safe': 'bg-success',
'read-only': 'bg-brand',
'interruptive': 'bg-warning',
'runtime-change': 'bg-warning',
'removes-containers': 'bg-destructive',
'destructive': 'bg-destructive',
};
export interface ScheduledActionDefinition {
id: ScheduledActionId;
/** The action value sent to the backend (`update-fleet` maps to `update`). */
@@ -38,7 +81,10 @@ export interface ScheduledActionDefinition {
requiresStack: boolean;
supportsServiceSelection: boolean;
nodeScope?: 'local';
helperText?: string;
/** One-line explanation shown below the action picker in the create/edit form. */
helperText: string;
/** Risk level shown as a coloured chip next to the helper text. */
riskLevel: ScheduledActionRiskLevel;
}
/** Action pre-selected when the create modal opens. Decoupled from picker order. */
@@ -47,20 +93,20 @@ export const DEFAULT_SCHEDULED_ACTION_ID: ScheduledActionId = 'restart';
/** Ordered for the create-flow action picker, grouped by category. */
export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [
// Lifecycle
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
{ id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
{ id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: true },
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' },
{ id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' },
{ id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' },
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' },
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' },
// Updates
{ 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 on Node', 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: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' },
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' },
// Security
{ id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Every image on the selected node will be scanned. Scans run on local nodes only.' },
{ id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' },
// Maintenance
{ id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Resources are pruned on the selected node. Prunes run on local nodes only.' },
{ id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' },
// Backups
{ id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false },
{ id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' },
];
const ACTION_BY_ID = new Map<string, ScheduledActionDefinition>(SCHEDULED_ACTIONS.map(a => [a.id, a]));