0.2.78
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user