2026-08-14 09:40:03 +12:00
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 ;
2026-08-15 22:26:17 +12:00
/* 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 );
}
2026-08-14 09:40:03 +12:00
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 ();
});
2026-08-15 22:26:17 +12:00
// 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 ();
});
2026-08-14 09:40:03 +12:00
const failures = tasks . filter (( task ) => task . lastRun ? . status === 'failed' ). length ;
2026-08-15 22:26:17 +12:00
const retimed = tasks . filter (
( task ) => task . defaultIntervalSeconds > 0 && task . intervalSeconds !== task . defaultIntervalSeconds ,
). length ;
2026-08-14 09:40:03 +12:00
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 ,
},
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' ,
value : num ( retimed ),
icon : 'clock' ,
tone : retimed > 0 ? 'note' : undefined ,
},
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 }
{[... 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 }
2026-08-15 22:26:17 +12:00
{ task . defaultIntervalSeconds > 0 &&
task . intervalSeconds !== task . defaultIntervalSeconds ? (
< Tag tone = "note" > retimed </ Tag >
) : null }
2026-08-14 09:40:03 +12:00
</ 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 >
)}
2026-08-15 22:26:17 +12:00
{ /* 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 }
2026-08-14 09:40:03 +12:00
< 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 >
</>
)}
</>
);
}