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

273 lines
11 KiB
TypeScript

import { useState } from 'react';
import { Link } 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 { compareTasks, retimed, runTone, statusDot, taskStatus } from '../lib/tasks';
import {
Banner,
Button,
Card,
EmptyRow,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { ScheduledTask, TasksResponse } from '../api/types';
/* Scheduled tasks: what the gateway does when nobody is watching.
*
* A directory, and — following the Users page it is now shaped like — very nearly only a
* directory. It was a stack of cards holding a paragraph, a select and two switches per
* task, which meant the one question this page is opened with, "is anything wrong", had to
* be answered by reading every entry in turn. One row per task answers it by scanning a
* column, and everything you can *do* to a task beyond starting it lives on the task's own
* page.
*
* Run now is the exception that stays in the row. It is the only action here that is not a
* change of configuration — it asks for something to happen once, and it is what an
* operator comes to this page to press.
*
* Polled faster than the console's own heartbeat while a task is running, because having
* pressed it the next thing they do is 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;
export function TasksPage() {
const { wrap } = useToast();
const { busy, run } = useAction();
const [fast, setFast] = useState(false);
const { data, error, loading, reload } = useQuery<TasksResponse>('/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 failures = tasks.filter((task) => task.lastRun?.status === 'failed').length;
const offSchedule = tasks.filter(retimed).length;
const disabled = tasks.filter((task) => !task.enabled).length;
const rows = [...tasks].sort(compareTasks);
return (
<>
<PageHead
title="Scheduled tasks"
intro="The gateway's background work: what it does, when it last ran, how long it took and whether it worked. Every one of these can be started by hand."
/>
<Banner message={error} />
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'Tasks', value: num(tasks.length), icon: 'clock', tone: 'info' },
{
label: 'Running now',
value: num(tasks.filter((task) => 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(offSchedule),
icon: 'clock',
tone: offSchedule > 0 ? 'note' : undefined,
},
]}
/>
{failures > 0 ? (
<Note tone="bad">
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.
</Note>
) : null}
<Card
title="Tasks"
intro="Anything wrong sorts to the top. Open a task to change its schedule, switch it off, or read its own run history."
icon="clock"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Task</th>
<th>Service</th>
<th>Schedule</th>
<th>Last run</th>
<th className="num">Took</th>
<th>Next run</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={8}>This gateway has no scheduled tasks registered.</EmptyRow>
) : (
rows.map((task) => {
const status = taskStatus(task);
const last = task.lastRun;
const href = `/admin/tasks/${encodeURIComponent(task.id)}`;
return (
<tr key={task.id}>
{/* The name is the row's primary element and everything under it
is quieter, so a column of forty reads as a list of names
rather than as forty paragraphs. */}
<td>
<Link className="table-row-link" to={href}>
{task.name}
</Link>
<span className="table-sub">{task.description}</span>
</td>
<td className="muted nowrap">{task.group || 'Other'}</td>
{/* The declared cadence sits under an overridden one rather than
beside it: what happens next is the answer, and what the code
asked for is the footnote explaining why the row is retimed. */}
<td className="nowrap">
{interval(task.intervalSeconds)}
{retimed(task) ? (
<span className="table-sub">
default {interval(task.defaultIntervalSeconds)}
</span>
) : null}
</td>
<td className="nowrap muted" title={last ? when(last.startedAt) : undefined}>
{last ? ago(last.startedAt) : 'never'}
{/* Only a failure earns a sub-line. A detail under every
successful row is a column of noise, and the detail is on
the task's own page either way. */}
{last?.error ? <span className="table-sub">{last.error}</span> : null}
</td>
<td className="num muted">{last ? duration(last.durationMs) : '—'}</td>
{/* A switched-off task still has a next run in the scheduler's
records and it is not going to happen, so the column says so
rather than printing a time that will pass with nothing at
the end of it. */}
<td className="nowrap muted">
{!task.enabled ? '—' : task.running ? 'now' : until(task.nextRun)}
</td>
<td className="nowrap">
<span className="row tight">
<span className="dot-state" data-tone={statusDot(status)} />
<Tag tone={status.tone}>
<span title={status.title}>{status.label}</span>
</Tag>
</span>
</td>
<td className="nowrap">
<span className="row tight">
<Button
size="sm"
icon="play"
busy={busy === task.id}
disabled={task.running}
title={`Run ${task.name} now`}
onClick={() => void runNow(task)}
/>
<Link className="table-row-link" to={href}>
Details
</Link>
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Recent runs"
intro="Every task together and in order, which is what shows two jobs interfering with each other."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Started</th>
<th>Task</th>
<th>Trigger</th>
<th>Result</th>
<th className="num">Took</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{(data?.runs.length ?? 0) === 0 ? (
<EmptyRow columns={6}>No task has run yet.</EmptyRow>
) : (
data?.runs.map((entry) => (
<tr key={entry.id}>
<td className="nowrap muted" title={when(entry.startedAt)}>
{ago(entry.startedAt)}
</td>
<td>
<Link
className="table-row-link"
to={`/admin/tasks/${encodeURIComponent(entry.taskId)}`}
>
{tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId}
</Link>
</td>
<td className="muted">{entry.trigger}</td>
<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>
</>
)}
</>
);
}