0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+232
View File
@@ -0,0 +1,232 @@
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;
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();
});
const failures = tasks.filter((task) => task.lastRun?.status === 'failed').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,
},
]}
/>
{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}
</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>
)}
<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>
</>
)}
</>
);
}