340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { api } from '../api/client';
|
|
import { useAction, useQuery } from '../lib/hooks';
|
|
import { useToast } from '../lib/toast';
|
|
import { ago, duration, interval, num, when } from '../lib/format';
|
|
import {
|
|
Banner,
|
|
Button,
|
|
Card,
|
|
EmptyRow,
|
|
Loading,
|
|
Note,
|
|
PageHead,
|
|
TableWrap,
|
|
Tag,
|
|
Tiles,
|
|
Toggle,
|
|
} from '../components/ui';
|
|
import type { ScheduledTask, TaskRun, TasksResponse } from '../api/types';
|
|
import type { Tone } from '../lib/format';
|
|
|
|
/* Scheduled tasks: what the gateway does when nobody is watching.
|
|
*
|
|
* Polled faster than the console's own heartbeat while a task is running, because the one
|
|
* thing an operator does here is press Run now and then 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;
|
|
|
|
/* The cadences an operator may choose from.
|
|
*
|
|
* A fixed list rather than a free-text duration, because the useful range here spans three
|
|
* orders of magnitude and the two ways to get it wrong are both silent: a number typed in
|
|
* the wrong unit, and a cadence so tight the job never finishes before it is due again. The
|
|
* floor matches the scheduler's own — it clamps anything under a minute — so the console
|
|
* cannot offer a value the server would quietly change underneath it.
|
|
*
|
|
* Zero is absent on purpose. The API reads it as "restore the declared cadence" rather than
|
|
* as "never", so a "run by hand only" entry here would appear to do nothing on any task that
|
|
* declares an interval. That is a server-side limitation and it belongs in the server, not
|
|
* in a control that lies about it. */
|
|
const CADENCE_CHOICES: { value: number; label: string }[] = [
|
|
{ value: 60, label: 'Every minute' },
|
|
{ value: 300, label: 'Every 5 minutes' },
|
|
{ value: 600, label: 'Every 10 minutes' },
|
|
{ value: 900, label: 'Every 15 minutes' },
|
|
{ value: 1_800, label: 'Every 30 minutes' },
|
|
{ value: 3_600, label: 'Hourly' },
|
|
{ value: 10_800, label: 'Every 3 hours' },
|
|
{ value: 21_600, label: 'Every 6 hours' },
|
|
{ value: 43_200, label: 'Every 12 hours' },
|
|
{ value: 86_400, label: 'Daily' },
|
|
{ value: 604_800, label: 'Weekly' },
|
|
];
|
|
|
|
/* The choices for one task: the presets, plus its own declared cadence and whatever it is
|
|
* currently set to if either falls outside the list.
|
|
*
|
|
* Adding them rather than snapping to the nearest preset is what stops the control being
|
|
* destructive to look at — a task declaring 45 minutes must not silently become hourly
|
|
* because somebody opened the page and the select had to show *something*. */
|
|
function cadenceChoices(task: ScheduledTask): { value: number; label: string }[] {
|
|
const choices = [...CADENCE_CHOICES];
|
|
for (const seconds of [task.defaultIntervalSeconds, task.intervalSeconds]) {
|
|
if (seconds > 0 && !choices.some((choice) => choice.value === seconds)) {
|
|
choices.push({ value: seconds, label: interval(seconds).replace(/^every /, 'Every ') });
|
|
}
|
|
}
|
|
return choices.sort((a, b) => a.value - b.value);
|
|
}
|
|
|
|
function statusTone(status: TaskRun['status']): Tone {
|
|
if (status === 'failed') return 'bad';
|
|
if (status === 'running') return 'info';
|
|
if (status === 'skipped') return 'warn';
|
|
return 'ok';
|
|
}
|
|
|
|
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 setEnabled = (task: ScheduledTask, enabled: boolean) =>
|
|
run(`${task.id}:enabled`, async () => {
|
|
await wrap(
|
|
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { enabled }),
|
|
enabled ? `${task.name} switched on.` : `${task.name} switched off.`,
|
|
);
|
|
await reload();
|
|
});
|
|
|
|
// Named setCadence rather than setInterval so it cannot shadow the global of that
|
|
// name inside this component, which is a trap for anything added here later.
|
|
const setCadence = (task: ScheduledTask, intervalSeconds: number) =>
|
|
run(`${task.id}:interval`, async () => {
|
|
await wrap(
|
|
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds }),
|
|
`${task.name} now runs ${interval(intervalSeconds)}.`,
|
|
);
|
|
await reload();
|
|
});
|
|
|
|
// 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 CADENCE_CHOICES.
|
|
const resetCadence = (task: ScheduledTask) =>
|
|
run(`${task.id}:interval`, async () => {
|
|
await wrap(
|
|
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds: 0 }),
|
|
`${task.name} back to its default cadence.`,
|
|
);
|
|
await reload();
|
|
});
|
|
|
|
const failures = tasks.filter((task) => task.lastRun?.status === 'failed').length;
|
|
const retimed = tasks.filter(
|
|
(task) => task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds,
|
|
).length;
|
|
const disabled = tasks.filter((task) => !task.enabled).length;
|
|
|
|
const groups = data?.groups ?? [];
|
|
const ungrouped = tasks.filter((task) => !task.group);
|
|
|
|
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(retimed),
|
|
icon: 'clock',
|
|
tone: retimed > 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}
|
|
|
|
{[...groups, ...(ungrouped.length > 0 ? [''] : [])].map((group) => {
|
|
const inGroup = tasks.filter((task) => task.group === group);
|
|
if (inGroup.length === 0) return null;
|
|
return (
|
|
<Card
|
|
key={group || 'other'}
|
|
title={group || 'Other'}
|
|
icon={group === 'System' ? 'chip' : group === 'Analytics' ? 'chart' : 'wrench'}
|
|
tone={group === 'System' ? 'info' : group === 'Analytics' ? 'data' : 'note'}
|
|
>
|
|
<div className="list">
|
|
{inGroup.map((task) => (
|
|
<div className="list-item" key={task.id}>
|
|
<div className="list-body">
|
|
<b>
|
|
{task.name}{' '}
|
|
{task.running ? <Tag tone="info">running</Tag> : null}
|
|
{!task.enabled ? <Tag tone="warn">off</Tag> : null}
|
|
{task.defaultIntervalSeconds > 0 &&
|
|
task.intervalSeconds !== task.defaultIntervalSeconds ? (
|
|
<Tag tone="note">retimed</Tag>
|
|
) : null}
|
|
</b>
|
|
<p>{task.description}</p>
|
|
<p className="quiet">
|
|
{interval(task.intervalSeconds)}
|
|
{task.enabled && task.nextRun ? ` · next ${ago(task.nextRun).replace(' ago', '')}` : ''}
|
|
{task.lastRun ? (
|
|
<>
|
|
{' · last '}
|
|
<span title={when(task.lastRun.startedAt)}>{ago(task.lastRun.startedAt)}</span>
|
|
{` in ${duration(task.lastRun.durationMs)}`}
|
|
{task.lastRun.detail ? ` — ${task.lastRun.detail}` : ''}
|
|
</>
|
|
) : (
|
|
' · never run'
|
|
)}
|
|
</p>
|
|
{task.lastRun?.error ? (
|
|
<p className="mono" style={undefined}>
|
|
<Tag tone="bad">{task.lastRun.error}</Tag>
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="list-actions">
|
|
{task.lastRun ? (
|
|
<Tag tone={statusTone(task.lastRun.status)}>{task.lastRun.status}</Tag>
|
|
) : (
|
|
<Tag>never run</Tag>
|
|
)}
|
|
{/* 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. */}
|
|
<select
|
|
aria-label={`How often ${task.name} runs`}
|
|
value={task.intervalSeconds}
|
|
disabled={busy === `${task.id}:interval` || task.running}
|
|
onChange={(event) => void setCadence(task, Number(event.target.value))}
|
|
>
|
|
{cadenceChoices(task).map((choice) => (
|
|
<option key={choice.value} value={choice.value}>
|
|
{choice.label}
|
|
{choice.value === task.defaultIntervalSeconds ? ' (default)' : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{task.defaultIntervalSeconds > 0 &&
|
|
task.intervalSeconds !== task.defaultIntervalSeconds ? (
|
|
<Button
|
|
size="sm"
|
|
icon="refresh"
|
|
busy={busy === `${task.id}:interval`}
|
|
onClick={() => void resetCadence(task)}
|
|
>
|
|
Default
|
|
</Button>
|
|
) : null}
|
|
<Toggle
|
|
label=""
|
|
checked={task.enabled}
|
|
disabled={busy === `${task.id}:enabled`}
|
|
onChange={(next) => void setEnabled(task, next)}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
icon="play"
|
|
busy={busy === task.id}
|
|
disabled={task.running}
|
|
onClick={() => void runNow(task)}
|
|
>
|
|
Run now
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</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>{tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId}</td>
|
|
<td className="muted">{entry.trigger}</td>
|
|
<td>
|
|
<Tag tone={statusTone(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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|