import { useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { api } from '../api/client'; import { useAction, useQuery } from '../lib/hooks'; import { useToast } from '../lib/toast'; import { ago, duration, interval, num, until, when } from '../lib/format'; import { cadenceChoices, retimed, runTone, taskStatus } from '../lib/tasks'; import { Banner, Button, Card, Empty, EmptyRow, Field, Loading, Note, PageHead, TableWrap, Tag, Tiles, Toggle, } from '../components/ui'; import type { ScheduledTask, TasksResponse } from '../api/types'; /* One scheduled task. * * The list is a directory and this is where a task is actually operated on: its cadence, * its switch, and its own run history rather than the whole gateway's. That split is the * Users/User one, and it is what let the list become a table an operator can scan — a * select and two switches per row is exactly the clutter that made forty tasks unreadable. * * It reads the same /admin/api/tasks the list does, with `task=` set, which is why there * is no second endpoint behind this page: the response already carries every task (so the * page can find the one it is about, and print it with the same fields the list used) and * the `task` parameter narrows only the run history. The limit is higher than the list's * because history is the whole reason somebody comes here. */ const IDLE_POLL_MS = 20_000; const BUSY_POLL_MS = 3_000; const HISTORY_LIMIT = 100; /** What the history says about the task, as opposed to what its last run says. * * A task that fails one run in twenty and a task that has failed every run since Tuesday * look identical from a status column, and the second is the one worth being told about. * Averaged over the window the page holds rather than over all time, because that is the * only thing it has — and it says how many runs it is averaging, so a figure drawn from * three runs cannot be mistaken for a settled one. */ function summarise(runs: { status: string; durationMs: number }[]) { const finished = runs.filter((run) => run.status !== 'running'); const failed = finished.filter((run) => run.status === 'failed').length; const timed = finished.filter((run) => run.durationMs > 0); const averageMs = timed.length ? timed.reduce((total, run) => total + run.durationMs, 0) / timed.length : 0; return { runs: finished.length, failed, averageMs, timed: timed.length }; } export function TaskPage() { const { taskId = '' } = useParams(); const { wrap } = useToast(); const { busy, run } = useAction(); const [fast, setFast] = useState(false); const { data, error, loading, reload } = useQuery( `/admin/api/tasks?task=${encodeURIComponent(taskId)}&limit=${HISTORY_LIMIT}`, { pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS, enabled: Boolean(taskId) }, ); const task = data?.tasks.find((entry) => entry.id === taskId); const running = Boolean(task?.running); if (running !== fast) setFast(running); const runs = data?.runs ?? []; const status = task ? taskStatus(task) : undefined; const summary = summarise(runs); const act = (key: string, message: string, body: Record) => run(key, async () => { await wrap(() => api.put(`/admin/api/tasks/${encodeURIComponent(taskId)}`, body), message); await reload(); }); const runNow = (subject: ScheduledTask) => run('run', async () => { await wrap( () => api.post(`/admin/api/tasks/${encodeURIComponent(taskId)}/run`), `${subject.name} started.`, ); await reload(); }); return ( <> Scheduled tasks / {task?.name || taskId} } actions={ task ? ( ) : undefined } /> {loading ? ( ) : !task ? ( /* A task id that no longer exists is an ordinary thing to arrive at — a bookmark, or a job removed in a deployment — so it is stated rather than left as an empty page, and the way back is named. */ This gateway has no task called {taskId}. It may have been renamed or removed.{' '} Back to scheduled tasks. ) : ( <> 0 ? 'bad' : undefined, }, // Averaged only over runs that recorded a duration, and the label says so: // a mean that quietly counted skipped runs as instant would understate // every job whose ordinary answer is "nothing to do". { label: `Average of ${num(summary.timed)} timed runs`, value: summary.timed ? duration(Math.round(summary.averageMs)) : '—', small: true, icon: 'chart', tone: 'data', }, ]} /> {task.lastRun?.error ? ( The last run failed: {task.lastRun.error} ) : null}
{/* 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. */} {/* 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 cadenceChoices. */} {retimed(task) ? ( ) : null}
void act( 'enabled', next ? `${task.name} switched on.` : `${task.name} switched off.`, { enabled: next }, ) } /> {retimed(task) ? ( This task is retimed: its code asks for {interval(task.defaultIntervalSeconds)} and it is set to {interval(task.intervalSeconds)}. ) : null}
{runs.length === 0 ? ( This task has not run yet. ) : ( runs.map((entry) => ( {/* The tag alone here, with no dot beside it. The status column on the list is scanned across forty unrelated rows and earns the second signal; this is one task's own history, where every row is already about the same thing. */} )) )}
Started Trigger Result Took Detail
{ago(entry.startedAt)} {entry.trigger} {entry.status} {duration(entry.durationMs)} {entry.error || entry.detail || '—'}
)} ); }