diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx
index c1c6a58f..4c0314b0 100644
--- a/docs/features/scheduled-operations.mdx
+++ b/docs/features/scheduled-operations.mdx
@@ -18,7 +18,7 @@ Schedules is a unified surface for every recurring maintenance operation Sencho
Open the **Schedules** tab from the top navigation bar. The page opens on the Timeline view with the masthead, the five lane track, and a bottom time axis.
-
+
## Timeline view
@@ -26,8 +26,8 @@ Open the **Schedules** tab from the top navigation bar. The page opens on the Ti
The Timeline plots every firing of every enabled task across a rolling 24-hour window starting from the current minute.
- **Masthead.** A `NEXT 24 HOURS` kicker, an italic display heading, the window's start and end timestamps in a monospace range, and a right-anchored **Next** pill that reads out the time and task name of the next firing and a relative countdown.
-- **Five lanes.** Lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Lifecycle lane holds the five stack-lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds node resource prunes; Backups holds fleet snapshots.
-- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and the task name. Pills are color-matched to their lane. Click a pill to open the run history sheet for that task.
+- **Five lanes.** Stack lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Stack lifecycle lane holds the five stack-lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds node resource prunes; Backups holds fleet snapshots.
+- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and a target: the stack for stack actions, the selected node for prune and scan, and "Entire fleet" for a fleet snapshot. Hover a pill for the full detail (action, task name, and node). Pills are color-matched to their lane. Click a pill to open the run history sheet for that task.
- **Now rail.** A glowing vertical rail at the current minute, anchored to the left of the track at page open and drifting right as time passes (the page recomputes positions periodically).
- **Axis.** Six monospace time ticks run along the bottom, evenly spaced through the window.
@@ -71,10 +71,10 @@ The All tasks toggle swaps the lane track for a sortable table.
## Creating a scheduled task
-Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation, grouped by category: Lifecycle, Updates, Security, Maintenance, and Backups.
+Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation, grouped by category: Stack lifecycle, Updates, Security, Maintenance, and Backups.
-
+
Common fields:
diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx
index 4a835a0d..971b2f9b 100644
--- a/frontend/src/components/ScheduledOperationsView.tsx
+++ b/frontend/src/components/ScheduledOperationsView.tsx
@@ -21,6 +21,7 @@ import {
SCHEDULED_ACTION_CATEGORIES,
getActionById,
resolveTaskAction,
+ scheduleTargetDescriptor,
DEFAULT_SCHEDULED_ACTION_ID,
RISK_BADGE_CLASSES,
RISK_DOT_CLASSES,
@@ -347,6 +348,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
const cronDescription = getCronDescription(formCron);
const cronFieldError = getCronFieldError(formCron);
const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]);
+ const nodeNameById = useMemo(() => new Map(nodes.map(n => [n.id, n.name])), [nodes]);
const actionOptions = useMemo(
() =>
SCHEDULED_ACTIONS.map(o => ({
@@ -498,9 +500,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
{lanePills.map((pill, idx) => {
const leftPct = ((pill.runAt - now) / TIMELINE_WINDOW_MS) * 100;
const clamped = Math.max(0, Math.min(100, leftPct));
- const targetLabel = pill.task.target_type === 'stack'
- ? pill.task.target_id ?? pill.task.name
- : pill.task.name;
+ const nodeName = pill.task.node_id != null ? nodeNameById.get(pill.task.node_id) : undefined;
+ const targetLabel = scheduleTargetDescriptor(pill.task, nodeName);
+ const actionLabel = resolveTaskAction(pill.task)?.label ?? pill.task.action;
+ const tooltip = `${actionLabel} · ${pill.task.name} · ${formatHourTick(pill.runAt)}`
+ + (nodeName ? ` · ${nodeName}` : '');
return (
{formatHourTick(pill.runAt)}
{targetLabel}
diff --git a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx
index 57f01aff..87cc7875 100644
--- a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx
+++ b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx
@@ -306,11 +306,48 @@ describe('ScheduledOperationsView', () => {
it('renders the five registry category lanes in the timeline view', async () => {
render( );
// Timeline is the default view; the lane track always renders.
- for (const lane of ['Lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
+ for (const lane of ['Stack lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
expect(await screen.findByText(lane)).toBeInTheDocument();
}
});
+ it('labels timeline pills with a category-aware target and a detailed tooltip', async () => {
+ const soon = Date.now() + 2 * 60 * 60 * 1000;
+ tasksFixture = [
+ makeTask({ id: 1, name: 'Nightly Snapshot', target_type: 'fleet', action: 'snapshot', node_id: null, next_runs: [soon] }),
+ makeTask({ id: 2, name: 'Nightly Prune', target_type: 'system', action: 'prune', node_id: 1, next_runs: [soon] }),
+ ];
+ render( );
+
+ // Snapshot pill reads "Entire fleet"; prune pill reads its node name.
+ expect(await screen.findByText('Entire fleet')).toBeInTheDocument();
+ expect(await screen.findByText('hub')).toBeInTheDocument();
+
+ // Tooltips carry the full action label, with the node when the task has one.
+ const prunePill = screen.getByText('hub').closest('button');
+ expect(prunePill).toHaveAttribute('title', expect.stringContaining('Prune Node Resources'));
+ expect(prunePill).toHaveAttribute('title', expect.stringContaining('hub'));
+ const snapshotPill = screen.getByText('Entire fleet').closest('button');
+ expect(snapshotPill).toHaveAttribute('title', expect.stringContaining('Create Fleet Snapshot'));
+ });
+
+ it('names the node on a fleet auto-update pill and composes the full tooltip', async () => {
+ const soon = Date.now() + 2 * 60 * 60 * 1000;
+ const hhmm = (ts: number) => {
+ const d = new Date(ts);
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
+ };
+ tasksFixture = [
+ makeTask({ id: 1, name: 'Fleet Update', target_type: 'fleet', action: 'update', node_id: 1, next_runs: [soon] }),
+ ];
+ render( );
+
+ expect(await screen.findByText('All stacks · hub')).toBeInTheDocument();
+ // Tooltip locks the ordered shape: action · name · time · node.
+ const pill = screen.getByText('All stacks · hub').closest('button');
+ expect(pill).toHaveAttribute('title', `Auto-update All Stacks on Node · Fleet Update · ${hhmm(soon)} · hub`);
+ });
+
it('offers every registry action in the create picker', async () => {
render( );
diff --git a/frontend/src/components/mobile/MobileSchedules.tsx b/frontend/src/components/mobile/MobileSchedules.tsx
index 1a7614f7..0fb6f2ab 100644
--- a/frontend/src/components/mobile/MobileSchedules.tsx
+++ b/frontend/src/components/mobile/MobileSchedules.tsx
@@ -1,8 +1,8 @@
-import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/api';
-import type { ScheduledTask } from '@/types/scheduling';
-import { resolveTaskAction } from '@/lib/scheduledActions';
+import type { NodeOption, ScheduledTask } from '@/types/scheduling';
+import { resolveTaskAction, scheduleTargetDescriptor } from '@/lib/scheduledActions';
import { Masthead, SectionHead, StateDot, type Tone } from './mobile-ui';
interface MobileSchedulesProps {
@@ -42,15 +42,6 @@ function dayLabel(ts: number, now: number): string {
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
-function targetLabel(task: ScheduledTask): string {
- if (task.target_type === 'stack') return (task.target_id ?? task.name).replace(/\.(ya?ml)$/, '');
- if (task.target_type === 'fleet') {
- if (task.action === 'update') return 'stacks';
- return 'fleet';
- }
- return task.target_type;
-}
-
interface UpcomingRun {
task: ScheduledTask;
runAt: number;
@@ -58,10 +49,30 @@ interface UpcomingRun {
export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
const [tasks, setTasks] = useState([]);
+ const [nodes, setNodes] = useState([]);
const [loading, setLoading] = useState(true);
const [now, setNow] = useState(() => Date.now());
const abortRef = useRef(null);
+ const fetchNodes = useCallback(async () => {
+ try {
+ const res = await apiFetch('/nodes', { localOnly: true });
+ if (!res.ok) {
+ console.error('Node poll failed:', res.status);
+ return;
+ }
+ const data = await res.json();
+ if (!Array.isArray(data)) {
+ console.error('Unexpected /nodes response shape');
+ return;
+ }
+ setNodes((data as { id: number; name: string; type: 'local' | 'remote' }[])
+ .map(n => ({ id: n.id, name: n.name, type: n.type })));
+ } catch (error) {
+ console.error('Failed to fetch nodes:', error);
+ }
+ }, []);
+
const fetchTasks = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
@@ -94,11 +105,24 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
};
}, [fetchTasks]);
+ useEffect(() => {
+ // Node names rarely change, so fetch once on mount rather than on the poll.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ void fetchNodes();
+ }, [fetchNodes]);
+
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(id);
}, []);
+ const nodeNameById = useMemo(() => new Map(nodes.map(n => [n.id, n.name])), [nodes]);
+ const describeTarget = useCallback(
+ (task: ScheduledTask) =>
+ scheduleTargetDescriptor(task, task.node_id != null ? nodeNameById.get(task.node_id) : undefined),
+ [nodeNameById],
+ );
+
const enabledCount = tasks.filter(t => t.enabled === 1).length;
const upcoming: UpcomingRun[] = tasks
.filter(t => t.enabled === 1 && t.next_runs && t.next_runs.length > 0)
@@ -116,7 +140,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
state={next ? hhmm(next.runAt) : '--:--'}
stateTone="brand"
live={false}
- meta={next ? `${relative(next.runAt, now)} · ${actionShortLabel(next.task)} ${targetLabel(next.task)}` : 'nothing scheduled'}
+ meta={next ? `${relative(next.runAt, now)} · ${actionShortLabel(next.task)} ${describeTarget(next.task)}` : 'nothing scheduled'}
right={headerActions}
/>
@@ -141,7 +165,7 @@ export function MobileSchedules({ headerActions }: MobileSchedulesProps) {
{hhmm(run.runAt)}
- {actionShortLabel(run.task)} {` ${targetLabel(run.task)}`}
+ {actionShortLabel(run.task)} {` ${describeTarget(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 ed8108f4..45233e13 100644
--- a/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx
+++ b/frontend/src/components/mobile/__tests__/MobileSchedules.test.tsx
@@ -43,18 +43,29 @@ function makeTask(overrides: Partial = {}): ScheduledTask {
};
}
+let tasksFixture: ScheduledTask[];
+let nodesFixture: { id: number; name: string; type: 'local' | 'remote' }[];
+
beforeEach(() => {
+ tasksFixture = [];
+ nodesFixture = [{ id: 1, name: 'hub', type: 'local' }, { id: 2, name: 'edge', type: 'remote' }];
mockedFetch.mockReset();
+ // The view fetches nodes and tasks separately, so dispatch by URL.
+ mockedFetch.mockImplementation(async (url: string) => {
+ if (url === '/nodes') return jsonResponse(nodesFixture);
+ if (url === '/scheduled-tasks') return jsonResponse(tasksFixture);
+ return jsonResponse({});
+ });
});
afterEach(() => vi.clearAllMocks());
describe('MobileSchedules', () => {
it('renders registry short labels for upcoming runs', async () => {
- mockedFetch.mockResolvedValue(jsonResponse([
+ tasksFixture = [
makeTask({ id: 1, action: 'auto_backup' }),
makeTask({ id: 2, action: 'auto_down', next_runs: [Date.now() + 7_200_000] }),
- ]));
+ ];
const { container } = render( );
@@ -67,9 +78,9 @@ describe('MobileSchedules', () => {
describe('update + fleet target', () => {
it('renders node-scoped copy, not fleet-ambiguous text', async () => {
- mockedFetch.mockResolvedValue(jsonResponse([
+ tasksFixture = [
makeTask({ id: 1, action: 'update', target_type: 'fleet', target_id: null, node_id: 2 }),
- ]));
+ ];
render( );
@@ -80,19 +91,43 @@ describe('MobileSchedules', () => {
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();
+ // The target names the node the fleet update runs against.
+ expect(await screen.findByText('All stacks · edge')).toBeInTheDocument();
});
});
- it('renders fleet target label for a snapshot task', async () => {
- mockedFetch.mockResolvedValue(jsonResponse([
+ it('renders "Entire fleet" target label for a snapshot task', async () => {
+ tasksFixture = [
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();
+ expect(await screen.findByText('Entire fleet')).toBeInTheDocument();
+ });
+
+ it('names the selected node for a node-scoped scan instead of "system"', async () => {
+ tasksFixture = [
+ makeTask({ id: 1, name: 'Vul Scan', action: 'scan', target_type: 'system', target_id: null, node_id: 1 }),
+ ];
+
+ render( );
+
+ expect(await screen.findByText('scan')).toBeInTheDocument();
+ expect(await screen.findByText('hub')).toBeInTheDocument();
+ expect(screen.queryByText('system')).not.toBeInTheDocument();
+ });
+
+ it('falls back to a generic label, not a raw id, when the node is unresolved', async () => {
+ // node_id 99 is absent from nodesFixture (deleted node, or names not yet loaded).
+ tasksFixture = [
+ makeTask({ id: 1, name: 'Orphan Scan', action: 'scan', target_type: 'system', target_id: null, node_id: 99 }),
+ ];
+
+ render( );
+
+ expect(await screen.findByText('Selected node')).toBeInTheDocument();
+ expect(screen.queryByText('99')).not.toBeInTheDocument();
+ expect(screen.queryByText('system')).not.toBeInTheDocument();
});
});
diff --git a/frontend/src/lib/__tests__/scheduledActions.test.ts b/frontend/src/lib/__tests__/scheduledActions.test.ts
index c0150580..c64146d0 100644
--- a/frontend/src/lib/__tests__/scheduledActions.test.ts
+++ b/frontend/src/lib/__tests__/scheduledActions.test.ts
@@ -10,6 +10,8 @@ import {
DEFAULT_SCHEDULED_ACTION_ID,
getActionById,
resolveTaskAction,
+ scheduleTargetDescriptor,
+ stripComposeExt,
RISK_LABEL,
RISK_TONE,
RISK_BADGE_CLASSES,
@@ -18,6 +20,9 @@ import {
type ScheduledActionCategory,
type ScheduledActionRiskLevel,
} from '../scheduledActions';
+import type { ScheduledTask } from '@/types/scheduling';
+
+type TargetTask = Pick;
const BACKEND_ACTIONS: BackendAction[] = [
'restart', 'snapshot', 'prune', 'update', 'scan',
@@ -173,4 +178,46 @@ describe('scheduledActions registry', () => {
expect(def?.riskLevel).toBe('runtime-change');
expect(def?.helperText).toBe('Checks every stack on the selected node and updates stacks with newer images.');
});
+
+ describe('stripComposeExt', () => {
+ it('drops a trailing .yml or .yaml and leaves other names alone', () => {
+ expect(stripComposeExt('web')).toBe('web');
+ expect(stripComposeExt('web.yml')).toBe('web');
+ expect(stripComposeExt('web.yaml')).toBe('web');
+ expect(stripComposeExt('')).toBe('');
+ expect(stripComposeExt('my.app')).toBe('my.app');
+ });
+ });
+
+ describe('scheduleTargetDescriptor', () => {
+ it('shows the stack name (without compose extension) for stack actions', () => {
+ const task: TargetTask = { action: 'restart', target_type: 'stack', target_id: 'web.yml', name: 'Nightly restart' };
+ expect(scheduleTargetDescriptor(task, 'hub')).toBe('web');
+ });
+
+ it('falls back to the task name when a stack target_id is missing', () => {
+ const task: TargetTask = { action: 'restart', target_type: 'stack', target_id: null, name: 'api.yaml' };
+ expect(scheduleTargetDescriptor(task)).toBe('api');
+ });
+
+ it('shows the node for a fleet auto-update, or a generic label without one', () => {
+ const task: TargetTask = { action: 'update', target_type: 'fleet', target_id: null, name: 'Fleet update' };
+ expect(scheduleTargetDescriptor(task, 'edge-1')).toBe('All stacks · edge-1');
+ expect(scheduleTargetDescriptor(task)).toBe('All stacks');
+ });
+
+ it('shows Entire fleet for a fleet snapshot regardless of node', () => {
+ const task: TargetTask = { action: 'snapshot', target_type: 'fleet', target_id: null, name: 'Nightly Snapshot' };
+ expect(scheduleTargetDescriptor(task, 'edge-1')).toBe('Entire fleet');
+ expect(scheduleTargetDescriptor(task)).toBe('Entire fleet');
+ });
+
+ it('shows the node for system actions (prune / scan), with a fallback', () => {
+ const scan: TargetTask = { action: 'scan', target_type: 'system', target_id: null, name: 'Vul Scan' };
+ const prune: TargetTask = { action: 'prune', target_type: 'system', target_id: null, name: 'Nightly Prune' };
+ expect(scheduleTargetDescriptor(scan, 'hub')).toBe('hub');
+ expect(scheduleTargetDescriptor(prune, 'edge-1')).toBe('edge-1');
+ expect(scheduleTargetDescriptor(scan)).toBe('Selected node');
+ });
+ });
});
diff --git a/frontend/src/lib/scheduledActions.ts b/frontend/src/lib/scheduledActions.ts
index 773e97cf..dc5876b4 100644
--- a/frontend/src/lib/scheduledActions.ts
+++ b/frontend/src/lib/scheduledActions.ts
@@ -129,6 +129,37 @@ export function resolveTaskAction(
return getActionById(task.action);
}
+/** Drop a trailing `.yml` / `.yaml` from a stack file name for display. */
+export function stripComposeExt(name: string): string {
+ return name.replace(/\.(ya?ml)$/, '');
+}
+
+/**
+ * Category-aware label for what a scheduled run acts on, used by the Timeline
+ * pills and the mobile schedule list. Stack actions show the stack, fleet
+ * snapshots show the whole fleet, fleet updates and node-scoped actions
+ * (prune / scan) show the selected node when its name is known.
+ */
+export function scheduleTargetDescriptor(
+ task: Pick,
+ nodeName?: string,
+): string {
+ switch (task.target_type) {
+ case 'stack':
+ return stripComposeExt(task.target_id ?? task.name);
+ case 'fleet':
+ return task.action === 'update'
+ ? (nodeName ? `All stacks · ${nodeName}` : 'All stacks')
+ : 'Entire fleet';
+ case 'system':
+ return nodeName ?? 'Selected node';
+ default: {
+ const exhaustive: never = task.target_type;
+ return exhaustive;
+ }
+ }
+}
+
export interface ScheduledActionCategoryLane {
key: ScheduledActionCategory;
label: string;
@@ -138,7 +169,7 @@ export interface ScheduledActionCategoryLane {
/** Ordered Timeline lanes; each scheduled action maps to one lane by category. */
export const SCHEDULED_ACTION_CATEGORIES: ScheduledActionCategoryLane[] = [
- { key: 'lifecycle', label: 'Lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)' },
+ { key: 'lifecycle', label: 'Stack lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)' },
{ key: 'updates', label: 'Updates', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)' },
{ key: 'security', label: 'Security', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)' },
{ key: 'maintenance', label: 'Maintenance', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)' },