This commit is contained in:
ponzischeme89
2026-08-19 14:25:44 +12:00
parent 2b43b9ef12
commit 590e069366
83 changed files with 8948 additions and 1266 deletions
+128 -195
View File
@@ -1,8 +1,10 @@
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, when } from '../lib/format';
import { ago, duration, interval, num, until, when } from '../lib/format';
import { compareTasks, retimed, runTone, statusDot, taskStatus } from '../lib/tasks';
import {
Banner,
Button,
@@ -14,69 +16,29 @@ import {
TableWrap,
Tag,
Tiles,
Toggle,
} from '../components/ui';
import type { ScheduledTask, TaskRun, TasksResponse } from '../api/types';
import type { Tone } from '../lib/format';
import type { ScheduledTask, TasksResponse } from '../api/types';
/* 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. */
* 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;
/* 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();
@@ -98,45 +60,11 @@ export function TasksPage() {
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 offSchedule = tasks.filter(retimed).length;
const disabled = tasks.filter((task) => !task.enabled).length;
const groups = data?.groups ?? [];
const ungrouped = tasks.filter((task) => !task.group);
const rows = [...tasks].sort(compareTasks);
return (
<>
@@ -177,9 +105,9 @@ export function TasksPage() {
// cadence currently in force.
{
label: 'Retimed',
value: num(retimed),
value: num(offSchedule),
icon: 'clock',
tone: retimed > 0 ? 'note' : undefined,
tone: offSchedule > 0 ? 'note' : undefined,
},
]}
/>
@@ -191,105 +119,103 @@ export function TasksPage() {
</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="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"
@@ -318,10 +244,17 @@ export function TasksPage() {
<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>
<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={statusTone(entry.status)}>{entry.status}</Tag>
<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>