feat: show node and fleet targets on schedule timeline pills (#1480)

Timeline pills and the mobile schedule list now identify what each
scheduled run acts on instead of repeating the task name. Pills stay
compact (firing time plus a category-aware target) and carry the full
detail on hover:

- Stack actions show the stack name.
- Fleet snapshots show "Entire fleet".
- Fleet auto-updates and node-scoped prune/scan show the selected node.
- The hover tooltip adds the action label, task name, and node.

The mobile list resolves node names too, so prune and scan rows name the
node rather than the literal "system". A shared scheduleTargetDescriptor
helper removes the target-label logic that was duplicated across the
desktop and mobile views. The lifecycle lane is renamed "Stack lifecycle"
to match the action-picker category wording.
This commit is contained in:
Anso
2026-06-26 22:15:06 -04:00
committed by GitHub
parent 1bca75a999
commit 5960c1e85e
7 changed files with 214 additions and 36 deletions
@@ -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 (
<button
key={`${pill.task.id}-${idx}-${pill.runAt}`}
@@ -516,7 +520,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
? 'translate(-100%, -50%)'
: 'translate(0, -50%)',
}}
title={`${pill.task.name} · ${formatHourTick(pill.runAt)} · ${targetLabel}`}
title={tooltip}
>
<span>{formatHourTick(pill.runAt)}</span>
<span className="opacity-70 max-w-[100px] truncate">{targetLabel}</span>
@@ -306,11 +306,48 @@ describe('ScheduledOperationsView', () => {
it('renders the five registry category lanes in the timeline view', async () => {
render(<ScheduledOperationsView />);
// 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(<ScheduledOperationsView />);
// 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(<ScheduledOperationsView />);
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(<ScheduledOperationsView />);
@@ -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<ScheduledTask[]>([]);
const [nodes, setNodes] = useState<NodeOption[]>([]);
const [loading, setLoading] = useState(true);
const [now, setNow] = useState(() => Date.now());
const abortRef = useRef<AbortController | null>(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) {
<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)}</span>{` ${targetLabel(run.task)}`}
<span className="text-stat-value">{actionShortLabel(run.task)}</span>{` ${describeTarget(run.task)}`}
</span>
<span className="shrink-0 font-mono text-[11px] text-stat-icon">{relative(run.runAt, now)}</span>
</div>
@@ -43,18 +43,29 @@ function makeTask(overrides: Partial<ScheduledTask> = {}): 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(<MobileSchedules headerActions={null} />);
@@ -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(<MobileSchedules headerActions={null} />);
@@ -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(<MobileSchedules headerActions={null} />);
// 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(<MobileSchedules headerActions={null} />);
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(<MobileSchedules headerActions={null} />);
expect(await screen.findByText('Selected node')).toBeInTheDocument();
expect(screen.queryByText('99')).not.toBeInTheDocument();
expect(screen.queryByText('system')).not.toBeInTheDocument();
});
});