Files
memby/admin-ui/src/pages/Task.tsx
T
2026-08-19 14:25:44 +12:00

307 lines
11 KiB
TypeScript

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<TasksResponse>(
`/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<string, unknown>) =>
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 (
<>
<PageHead
title={task?.name || taskId}
intro={task?.description}
icon="clock"
crumbs={
<>
<Link to="/admin/tasks">Scheduled tasks</Link>
<span>/</span>
<span>{task?.name || taskId}</span>
</>
}
actions={
task ? (
<Button
variant="primary"
icon="play"
busy={busy === 'run'}
disabled={task.running}
onClick={() => void runNow(task)}
>
{task.running ? 'Running' : 'Run now'}
</Button>
) : undefined
}
/>
<Banner message={error} />
{loading ? (
<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. */
<Card title="No such task" icon="alert" tone="bad">
<Empty>
This gateway has no task called <code>{taskId}</code>. It may have been renamed or removed.{' '}
<Link to="/admin/tasks">Back to scheduled tasks</Link>.
</Empty>
</Card>
) : (
<>
<Tiles
tiles={[
{
label: 'Status',
value: status?.label ?? '—',
small: true,
icon: 'pulse',
tone: status?.tone,
},
{ label: 'Service', value: task.group || 'Other', small: true, icon: 'chip', tone: 'info' },
{
label: 'Runs every',
value: interval(task.intervalSeconds),
small: true,
icon: 'clock',
tone: retimed(task) ? 'note' : undefined,
},
{
label: 'Next run',
value: task.enabled ? (task.running ? 'now' : until(task.nextRun)) : 'not scheduled',
small: true,
icon: 'history',
tone: task.enabled ? undefined : 'warn',
},
{
label: `Failed of the last ${num(summary.runs)}`,
value: num(summary.failed),
icon: 'alert',
tone: summary.failed > 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 ? (
<Note tone="bad">
The last run failed: {task.lastRun.error}
</Note>
) : null}
<Card
title="Schedule"
intro="How often the gateway runs this on its own, and whether it runs it at all."
icon="sliders"
tone="info"
>
<div className="row">
{/* 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. */}
<Field label="How often it runs">
<select
aria-label={`How often ${task.name} runs`}
value={task.intervalSeconds}
disabled={busy === 'interval' || task.running}
onChange={(event) =>
void act(
'interval',
`${task.name} now runs ${interval(Number(event.target.value))}.`,
{ intervalSeconds: Number(event.target.value) },
)
}
>
{cadenceChoices(task).map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
{choice.value === task.defaultIntervalSeconds ? ' (default)' : ''}
</option>
))}
</select>
</Field>
{/* 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) ? (
<Button
icon="refresh"
busy={busy === 'interval'}
disabled={task.running}
onClick={() =>
void act('interval', `${task.name} back to its default cadence.`, {
intervalSeconds: 0,
})
}
>
Back to {interval(task.defaultIntervalSeconds)}
</Button>
) : null}
</div>
<Toggle
label="Run this on its schedule"
hint="Switched off, the gateway leaves it alone. You can still start it by hand."
checked={task.enabled}
disabled={busy === 'enabled'}
onChange={(next) =>
void act(
'enabled',
next ? `${task.name} switched on.` : `${task.name} switched off.`,
{ enabled: next },
)
}
/>
{retimed(task) ? (
<Note tone="note">
This task is retimed: its code asks for {interval(task.defaultIntervalSeconds)} and it is
set to {interval(task.intervalSeconds)}.
</Note>
) : null}
</Card>
<Card
title="Run history"
intro="This task alone, newest first — which is what separates a job that fails occasionally from one that has stopped working."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Started</th>
<th>Trigger</th>
<th>Result</th>
<th className="num">Took</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<EmptyRow columns={5}>This task has not run yet.</EmptyRow>
) : (
runs.map((entry) => (
<tr key={entry.id}>
<td className="nowrap muted" title={when(entry.startedAt)}>
{ago(entry.startedAt)}
</td>
<td className="muted">{entry.trigger}</td>
{/* 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. */}
<td>
<Tag tone={runTone(entry.status)}>{entry.status}</Tag>
</td>
<td className="num muted">{duration(entry.durationMs)}</td>
<td className="muted">{entry.error || entry.detail || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}