mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
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:
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/scheduled-operations/timeline.png" alt="Schedules page in Timeline view. Header reads 'Scheduled Operations' on the left with a clock icon; on the right are a Timeline / All tasks toggle, a Refresh button, and a 'New Schedule' primary button. Below, a kicker 'NEXT 24 HOURS' sits above an italic display heading 'Next 24 hours' and a monospace date range 'Thu, May 14 11:21 → Fri, May 15 11:21'. A right-aligned 'Next' pill shows '03:00' with the subtitle 'Vul Scan · in 15h 38m'. Five lanes (Lifecycle, Updates, Security, Maintenance, Backups) run horizontally; a purple '03:00 Vul Scan' pill sits on the Security lane and a cyan '04:00 Nightly Snapshot' pill sits on the Backups lane. A cyan vertical 'now' rail glows at the left edge. Six time ticks (11:21, 16:09, 20:57, 01:45, 06:33, 11:21) run along the bottom axis." />
|
||||
<img src="/images/scheduled-operations/timeline.png" alt="Schedules page in Timeline view. Header reads 'Scheduled Operations' on the left with a clock icon; on the right are a Timeline / All tasks toggle, a Refresh button, and a 'New Schedule' primary button. Below, a kicker 'NEXT 24 HOURS' sits above an italic display heading 'Next 24 hours' and a monospace date range 'Thu, May 14 11:21 → Fri, May 15 11:21'. A right-aligned 'Next' pill shows '03:00' with the subtitle 'Vul Scan · in 15h 38m'. Five lanes (Stack lifecycle, Updates, Security, Maintenance, Backups) run horizontally; a purple '03:00 hub' pill sits on the Security lane and a cyan '04:00 Entire fleet' pill sits on the Backups lane. A cyan vertical 'now' rail glows at the left edge. Six time ticks (11:21, 16:09, 20:57, 01:45, 06:33, 11:21) run along the bottom axis." />
|
||||
</Frame>
|
||||
|
||||
## 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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/scheduled-operations/action-picker.png" alt="The New scheduled task modal with the Action combobox expanded. The dropdown groups actions under category headers: Lifecycle (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down), Updates (Auto-update Stack, Auto-update All Stacks on Node), Security (Scan Node Images), Maintenance (Prune Node Resources), and Backups (Create Fleet Snapshot). 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." />
|
||||
<img src="/images/scheduled-operations/action-picker.png" alt="The New scheduled task modal with the Action combobox expanded. The dropdown groups actions under category headers: Stack lifecycle (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down), Updates (Auto-update Stack, Auto-update All Stacks on Node), Security (Scan Node Images), Maintenance (Prune Node Resources), and Backups (Create Fleet Snapshot). 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." />
|
||||
</Frame>
|
||||
|
||||
Common fields:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'name'>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ScheduledTask, 'action' | 'target_type' | 'target_id' | 'name'>,
|
||||
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)' },
|
||||
|
||||
Reference in New Issue
Block a user