307 lines
11 KiB
TypeScript
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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|