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, until, when } from '../lib/format'; import { compareTasks, retimed, runTone, statusDot, taskStatus } from '../lib/tasks'; import { Banner, Button, Card, EmptyRow, Loading, Note, PageHead, TableWrap, Tag, Tiles, } from '../components/ui'; import type { ScheduledTask, TasksResponse } from '../api/types'; /* Scheduled tasks: what the gateway does when nobody is watching. * * 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; export function TasksPage() { const { wrap } = useToast(); const { busy, run } = useAction(); const [fast, setFast] = useState(false); const { data, error, loading, reload } = useQuery('/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 failures = tasks.filter((task) => task.lastRun?.status === 'failed').length; const offSchedule = tasks.filter(retimed).length; const disabled = tasks.filter((task) => !task.enabled).length; const rows = [...tasks].sort(compareTasks); return ( <> {loading ? ( ) : ( <> 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(offSchedule), icon: 'clock', tone: offSchedule > 0 ? 'note' : undefined, }, ]} /> {failures > 0 ? ( 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. ) : null} {rows.length === 0 ? ( This gateway has no scheduled tasks registered. ) : ( rows.map((task) => { const status = taskStatus(task); const last = task.lastRun; const href = `/admin/tasks/${encodeURIComponent(task.id)}`; return ( {/* 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. */} {/* 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. */} {/* 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. */} ); }) )}
Task Service Schedule Last run Took Next run Status
{task.name} {task.description} {task.group || 'Other'} {interval(task.intervalSeconds)} {retimed(task) ? ( default {interval(task.defaultIntervalSeconds)} ) : null} {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 ? {last.error} : null} {last ? duration(last.durationMs) : '—'} {!task.enabled ? '—' : task.running ? 'now' : until(task.nextRun)} {status.label}
{(data?.runs.length ?? 0) === 0 ? ( No task has run yet. ) : ( data?.runs.map((entry) => ( )) )}
Started Task Trigger Result Took Detail
{ago(entry.startedAt)} {tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId} {entry.trigger} {entry.status} {duration(entry.durationMs)} {entry.error || entry.detail || '—'}
)} ); }