import { useState } from 'react'; import { api } from '../api/client'; import { useAction, useQuery } from '../lib/hooks'; import { useToast } from '../lib/toast'; import { ago, duration, interval, num, when } from '../lib/format'; import { Banner, Button, Card, EmptyRow, Loading, Note, PageHead, TableWrap, Tag, Tiles, Toggle, } from '../components/ui'; import type { ScheduledTask, TaskRun, TasksResponse } from '../api/types'; import type { Tone } from '../lib/format'; /* Scheduled tasks: what the gateway does when nobody is watching. * * Polled faster than the console's own heartbeat while a task is running, because the one * thing an operator does here is press Run now and then watch for the outcome — and a * thirty-second poll makes a two-second job look like one that did nothing. */ const IDLE_POLL_MS = 20_000; const BUSY_POLL_MS = 3_000; /* The cadences an operator may choose from. * * A fixed list rather than a free-text duration, because the useful range here spans three * orders of magnitude and the two ways to get it wrong are both silent: a number typed in * the wrong unit, and a cadence so tight the job never finishes before it is due again. The * floor matches the scheduler's own — it clamps anything under a minute — so the console * cannot offer a value the server would quietly change underneath it. * * Zero is absent on purpose. The API reads it as "restore the declared cadence" rather than * as "never", so a "run by hand only" entry here would appear to do nothing on any task that * declares an interval. That is a server-side limitation and it belongs in the server, not * in a control that lies about it. */ const CADENCE_CHOICES: { value: number; label: string }[] = [ { value: 60, label: 'Every minute' }, { value: 300, label: 'Every 5 minutes' }, { value: 600, label: 'Every 10 minutes' }, { value: 900, label: 'Every 15 minutes' }, { value: 1_800, label: 'Every 30 minutes' }, { value: 3_600, label: 'Hourly' }, { value: 10_800, label: 'Every 3 hours' }, { value: 21_600, label: 'Every 6 hours' }, { value: 43_200, label: 'Every 12 hours' }, { value: 86_400, label: 'Daily' }, { value: 604_800, label: 'Weekly' }, ]; /* The choices for one task: the presets, plus its own declared cadence and whatever it is * currently set to if either falls outside the list. * * Adding them rather than snapping to the nearest preset is what stops the control being * destructive to look at — a task declaring 45 minutes must not silently become hourly * because somebody opened the page and the select had to show *something*. */ function cadenceChoices(task: ScheduledTask): { value: number; label: string }[] { const choices = [...CADENCE_CHOICES]; for (const seconds of [task.defaultIntervalSeconds, task.intervalSeconds]) { if (seconds > 0 && !choices.some((choice) => choice.value === seconds)) { choices.push({ value: seconds, label: interval(seconds).replace(/^every /, 'Every ') }); } } return choices.sort((a, b) => a.value - b.value); } function statusTone(status: TaskRun['status']): Tone { if (status === 'failed') return 'bad'; if (status === 'running') return 'info'; if (status === 'skipped') return 'warn'; return 'ok'; } export function TasksPage() { const { wrap } = useToast(); const { busy, run } = useAction(); const [fast, setFast] = useState(false); const { data, error, loading, reload } = useQuery('/admin/api/tasks?limit=60', { pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS, }); const tasks = data?.tasks ?? []; const anyRunning = tasks.some((task) => task.running); if (anyRunning !== fast) setFast(anyRunning); const runNow = (task: ScheduledTask) => run(task.id, async () => { await wrap( () => api.post(`/admin/api/tasks/${encodeURIComponent(task.id)}/run`), `${task.name} started.`, ); await reload(); }); const setEnabled = (task: ScheduledTask, enabled: boolean) => run(`${task.id}:enabled`, async () => { await wrap( () => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { enabled }), enabled ? `${task.name} switched on.` : `${task.name} switched off.`, ); await reload(); }); // Named setCadence rather than setInterval so it cannot shadow the global of that // name inside this component, which is a trap for anything added here later. const setCadence = (task: ScheduledTask, intervalSeconds: number) => run(`${task.id}:interval`, async () => { await wrap( () => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds }), `${task.name} now runs ${interval(intervalSeconds)}.`, ); await reload(); }); // Sending zero is how the API is told to forget an override, so this is a separate call // from the select rather than an option inside it — see CADENCE_CHOICES. const resetCadence = (task: ScheduledTask) => run(`${task.id}:interval`, async () => { await wrap( () => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds: 0 }), `${task.name} back to its default cadence.`, ); await reload(); }); const failures = tasks.filter((task) => task.lastRun?.status === 'failed').length; const retimed = tasks.filter( (task) => task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds, ).length; const disabled = tasks.filter((task) => !task.enabled).length; const groups = data?.groups ?? []; const ungrouped = tasks.filter((task) => !task.group); return ( <> {loading ? ( ) : ( <> task.running).length), icon: 'pulse', tone: anyRunning ? 'ok' : undefined, }, { label: 'Last run failed', value: num(failures), icon: 'alert', tone: failures > 0 ? 'bad' : undefined, }, { label: 'Switched off', value: num(disabled), icon: 'power', tone: disabled > 0 ? 'warn' : undefined, }, // Worth a tile of its own: a retimed task is the most likely explanation for // "why has this not run", and it is invisible on a page that only prints the // cadence currently in force. { label: 'Retimed', value: num(retimed), icon: 'clock', tone: retimed > 0 ? 'note' : undefined, }, ]} /> {failures > 0 ? ( A failed task publishes an administrative event, so the failure is in the activity feed and wherever your integrations send it — you did not have to be looking at this page. ) : null} {[...groups, ...(ungrouped.length > 0 ? [''] : [])].map((group) => { const inGroup = tasks.filter((task) => task.group === group); if (inGroup.length === 0) return null; return (
{inGroup.map((task) => (
{task.name}{' '} {task.running ? running : null} {!task.enabled ? off : null} {task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds ? ( retimed ) : null}

{task.description}

{interval(task.intervalSeconds)} {task.enabled && task.nextRun ? ` · next ${ago(task.nextRun).replace(' ago', '')}` : ''} {task.lastRun ? ( <> {' · last '} {ago(task.lastRun.startedAt)} {` in ${duration(task.lastRun.durationMs)}`} {task.lastRun.detail ? ` — ${task.lastRun.detail}` : ''} ) : ( ' · never run' )}

{task.lastRun?.error ? (

{task.lastRun.error}

) : null}
{task.lastRun ? ( {task.lastRun.status} ) : ( never run )} {/* Disabled while a run is in flight: changing the cadence reschedules from now, and doing that underneath a running job is how one run silently becomes two. */} {task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds ? ( ) : null} void setEnabled(task, next)} />
))}
); })} {(data?.runs.length ?? 0) === 0 ? ( No task has run yet. ) : ( data?.runs.map((entry) => ( )) )}
Started Task Trigger Result Took Detail
{ago(entry.startedAt)} {tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId} {entry.trigger} {entry.status} {duration(entry.durationMs)} {entry.error || entry.detail || '—'}
)} ); }