diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx
index 4cde9b28..c1c6a58f 100644
--- a/docs/features/scheduled-operations.mdx
+++ b/docs/features/scheduled-operations.mdx
@@ -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).
-
+
### Prune label filter
diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx
index 30e37974..4a835a0d 100644
--- a/frontend/src/components/ScheduledOperationsView.tsx
+++ b/frontend/src/components/ScheduledOperationsView.tsx
@@ -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 && (
+
+
+
+ {RISK_LABEL[currentAction.riskLevel]}
+
+
{currentAction.helperText}
+
+ )}
{currentAction?.requiresStack && (
@@ -750,9 +766,6 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
onValueChange={setFormNodeId}
placeholder="Select node..."
/>
- {currentAction.helperText && (
- {currentAction.helperText}
- )}
)}
diff --git a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx
index 724c2737..57f01aff 100644
--- a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx
+++ b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ });
+ });
});
diff --git a/frontend/src/components/mobile/MobileSchedules.tsx b/frontend/src/components/mobile/MobileSchedules.tsx
index 3d20f259..1a7614f7 100644
--- a/frontend/src/components/mobile/MobileSchedules.tsx
+++ b/frontend/src/components/mobile/MobileSchedules.tsx
@@ -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 (
{day !== prevDay ? {day} : null}
@@ -138,7 +141,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
{hhmm(run.runAt)}
- {actionShortLabel(run.task.action)}{` ${targetLabel(run.task)}`}
+ {actionShortLabel(run.task)}{` ${targetLabel(run.task)}`}
{relative(run.runAt, now)}
diff --git a/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx b/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx
index 4a62a7c6..ed8108f4 100644
--- a/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx
+++ b/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx
@@ -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();
+
+ 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();
+
+ // Snapshot IS fleet-wide; 'fleet' in the target label is correct.
+ expect(await screen.findByText('fleet')).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/lib/__tests__/scheduledActions.test.ts b/frontend/src/lib/__tests__/scheduledActions.test.ts
index 988c27e9..c0150580 100644
--- a/frontend/src/lib/__tests__/scheduledActions.test.ts
+++ b/frontend/src/lib/__tests__/scheduledActions.test.ts
@@ -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 = {
+ '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 = {
+ '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.');
+ });
});
diff --git a/frontend/src/lib/scheduledActions.ts b/frontend/src/lib/scheduledActions.ts
index 3929e32e..773e97cf 100644
--- a/frontend/src/lib/scheduledActions.ts
+++ b/frontend/src/lib/scheduledActions.ts
@@ -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 = {
+ '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 = {
+ '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 = {
+ '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 = {
+ '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(SCHEDULED_ACTIONS.map(a => [a.id, a]));