2026-08-14 09:40:03 +12:00
import { useState } from 'react' ;
2026-08-19 14:25:44 +12:00
import { Link } from 'react-router-dom' ;
2026-08-14 09:40:03 +12:00
import { api } from '../api/client' ;
import { useAction , useQuery } from '../lib/hooks' ;
import { useToast } from '../lib/toast' ;
2026-08-19 14:25:44 +12:00
import { ago , duration , interval , num , until , when } from '../lib/format' ;
import { compareTasks , retimed , runTone , statusDot , taskStatus } from '../lib/tasks' ;
2026-08-14 09:40:03 +12:00
import {
Banner ,
Button ,
Card ,
EmptyRow ,
Loading ,
Note ,
PageHead ,
TableWrap ,
Tag ,
Tiles ,
} from '../components/ui' ;
2026-08-19 14:25:44 +12:00
import type { ScheduledTask , TasksResponse } from '../api/types' ;
2026-08-14 09:40:03 +12:00
/* Scheduled tasks: what the gateway does when nobody is watching.
*
2026-08-19 14:25:44 +12:00
* 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. */
2026-08-14 09:40:03 +12:00
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 < 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 failures = tasks . filter (( task ) => task . lastRun ? . status === 'failed' ). length ;
2026-08-19 14:25:44 +12:00
const offSchedule = tasks . filter ( retimed ). length ;
2026-08-14 09:40:03 +12:00
const disabled = tasks . filter (( task ) => ! task . enabled ). length ;
2026-08-19 14:25:44 +12:00
const rows = [... tasks ]. sort ( compareTasks );
2026-08-14 09:40:03 +12:00
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 ,
},
2026-08-15 22:26:17 +12:00
// 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' ,
2026-08-19 14:25:44 +12:00
value : num ( offSchedule ),
2026-08-15 22:26:17 +12:00
icon : 'clock' ,
2026-08-19 14:25:44 +12:00
tone : offSchedule > 0 ? 'note' : undefined ,
2026-08-15 22:26:17 +12:00
},
2026-08-14 09:40:03 +12:00
]}
/>
{ 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 }
2026-08-19 14:25:44 +12:00
< 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 >
2026-08-14 09:40:03 +12:00
< 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 >
2026-08-19 14:25:44 +12:00
< td >
< Link
className = "table-row-link"
to = { `/admin/tasks/ ${ encodeURIComponent ( entry . taskId ) } ` }
>
{ tasks . find (( task ) => task . id === entry . taskId ) ? . name ?? entry . taskId }
</ Link >
</ td >
2026-08-14 09:40:03 +12:00
< td className = "muted" >{ entry . trigger }</ td >
< td >
2026-08-19 14:25:44 +12:00
< Tag tone = { runTone ( entry . status )}>{ entry . status }</ Tag >
2026-08-14 09:40:03 +12:00
</ td >
< td className = "num muted" >{ duration ( entry . durationMs )}</ td >
< td className = "muted" >{ entry . error || entry . detail || '—' }</ td >
</ tr >
))
)}
</ tbody >
</ table >
</ TableWrap >
</ Card >
</>
)}
</>
);
}