@@ -0,0 +1,527 @@
import { CONFIG } from './config.js' ;
import { getApi } from './api.js' ;
import {
parseDate , formatDateTime , formatTime , formatRelative , formatDuration , formatBytes ,
} from './format.js' ;
const api = await getApi ( ) ;
// ---------- Domain ----------
// rank drives default sort: worst first
const STATUS = {
failed : { label : 'Failed' , icon : '✕' , rank : 0 } ,
warning : { label : 'Warning' , icon : '!' , rank : 1 } ,
unknown : { label : 'Unknown' , icon : '?' , rank : 2 } ,
running : { label : 'Running' , icon : '↻' , rank : 3 } ,
success : { label : 'Success' , icon : '✓' , rank : 4 } ,
} ;
// Anything the backend sends that we don't recognise becomes "unknown" rather than breaking the UI
function normalizeStatus ( raw ) {
const s = String ( raw ? ? '' ) . toLowerCase ( ) ;
return Object . hasOwn ( STATUS , s ) ? s : 'unknown' ;
}
function normalizeJob ( j ) {
return {
id : String ( j . id ) ,
company : j . company _name ? ? '' ,
name : j . job _name ? ? '' ,
status : normalizeStatus ( j . status ) ,
message : j . message ? ? '' ,
runningSince : parseDate ( j . running _since ) ,
// When the most recent run *finished*; a job that's running keeps showing its previous finish
lastRun : parseDate ( j . last _run _at ) ,
nextRun : parseDate ( j . next _run _at ) ,
} ;
}
// A running job isn't silently dead, so it's never overdue (a long run can pass its next slot)
const isOverdue = ( job , now = Date . now ( ) ) =>
job . status !== 'running' && ! ! job . nextRun && job . nextRun . getTime ( ) + CONFIG . overdueGraceMinutes * 60_000 < now ;
// ---------- State ----------
const state = {
jobs : [ ] ,
filter : 'all' ,
search : '' ,
sort : { key : 'status' , dir : 1 } ,
lastSuccess : null ,
} ;
// ---------- DOM ----------
const $ = ( sel ) => document . querySelector ( sel ) ;
const dom = {
tbody : $ ( '#jobs-body' ) ,
empty : $ ( '#empty-state' ) ,
search : $ ( '#search' ) ,
refreshBtn : $ ( '#refresh-btn' ) ,
lastUpdated : $ ( '#last-updated' ) ,
errorBanner : $ ( '#error-banner' ) ,
dialog : $ ( '#detail-dialog' ) ,
detailTitle : $ ( '#detail-title' ) ,
detailSubtitle : $ ( '#detail-subtitle' ) ,
detailBody : $ ( '#detail-body' ) ,
} ;
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
function el ( tag , props = { } , ... children ) {
const node = document . createElement ( tag ) ;
for ( const [ k , v ] of Object . entries ( props ) ) {
if ( v == null || v === false ) continue ;
if ( k === 'class' ) node . className = v ;
else if ( k === 'dataset' ) Object . assign ( node . dataset , v ) ;
else node . setAttribute ( k , v === true ? '' : v ) ;
}
node . append ( ... children . flat ( ) . filter ( ( c ) => c != null && c !== false ) ) ;
return node ;
}
// ---------- Rendering ----------
function statusBadge ( status , { asButton = false , opensDialog = false , dataset = null , title = null } = { } ) {
const { label , icon } = STATUS [ status ] ;
const content = [ el ( 'span' , { class : 'badge__icon' , 'aria-hidden' : 'true' } , icon ) , label ] ;
return asButton
? el ( 'button' , {
type : 'button' ,
class : ` badge badge-- ${ status } ` ,
'aria-haspopup' : opensDialog ? 'dialog' : null ,
'aria-label' : ` ${ label } – view details ` ,
title ,
dataset ,
} , content )
: el ( 'span' , { class : ` badge badge-- ${ status } ` , title } , content ) ;
}
const runningTitle = ( since ) => ( since ? ` Running since ${ formatDateTime ( since ) } ` : null ) ;
function timeCell ( date , extra = null ) {
if ( ! date ) return el ( 'td' , { } , el ( 'span' , { class : 'muted' } , 'Never' ) ) ;
return el ( 'td' , { } ,
el ( 'time' , { class : 'time' , datetime : date . toISOString ( ) , title : date . toString ( ) } ,
el ( 'span' , { } , formatDateTime ( date ) , extra ) ,
el ( 'span' , { class : 'time__rel' } , formatRelative ( date ) ) ) ) ;
}
function renderRow ( job ) {
const overdueTag = isOverdue ( job ) ? el ( 'span' , { class : 'tag-overdue' } , 'Overdue' ) : null ;
return el ( 'tr' , { } ,
el ( 'td' , { } , job . company ) ,
el ( 'td' , { } , job . name ) ,
el ( 'td' , { } , statusBadge ( job . status , {
asButton : true , opensDialog : true , dataset : { jobId : job . id } , title : runningTitle ( job . runningSince ) ,
} ) ) ,
timeCell ( job . lastRun ) ,
timeCell ( job . nextRun , overdueTag ) ) ;
}
const collator = new Intl . Collator ( undefined , { sensitivity : 'base' , numeric : true } ) ;
function cmpDate ( a , b ) {
if ( ! a && ! b ) return 0 ;
if ( ! a ) return 1 ;
if ( ! b ) return - 1 ;
return a - b ;
}
const SORTERS = {
company : ( a , b ) => collator . compare ( a . company , b . company ) ,
name : ( a , b ) => collator . compare ( a . name , b . name ) ,
status : ( a , b ) => STATUS [ a . status ] . rank - STATUS [ b . status ] . rank ,
lastRun : ( a , b ) => cmpDate ( a . lastRun , b . lastRun ) ,
nextRun : ( a , b ) => cmpDate ( a . nextRun , b . nextRun ) ,
} ;
function visibleJobs ( ) {
const q = state . search . trim ( ) . toLowerCase ( ) ;
const now = Date . now ( ) ;
const { key , dir } = state . sort ;
return state . jobs
. filter ( ( j ) => {
if ( state . filter === 'overdue' ) return isOverdue ( j , now ) ;
return state . filter === 'all' || j . status === state . filter ;
} )
. filter ( ( j ) => ! q || j . company . toLowerCase ( ) . includes ( q ) || j . name . toLowerCase ( ) . includes ( q ) )
// Stable tie-breakers keep row order from jumping around on each refresh
. sort ( ( a , b ) => SORTERS [ key ] ( a , b ) * dir || SORTERS . company ( a , b ) || SORTERS . name ( a , b ) ) ;
}
function renderSummary ( ) {
const now = Date . now ( ) ;
const counts = { all : state . jobs . length , failed : 0 , warning : 0 , running : 0 , success : 0 , overdue : 0 } ;
for ( const j of state . jobs ) {
if ( j . status in counts ) counts [ j . status ] ++ ;
if ( isOverdue ( j , now ) ) counts . overdue ++ ;
}
for ( const [ k , v ] of Object . entries ( counts ) ) {
const node = document . querySelector ( ` [data-count=" ${ k } "] ` ) ;
if ( node ) node . textContent = v ;
}
document . querySelectorAll ( '.summary-card' ) . forEach ( ( card ) => {
card . setAttribute ( 'aria-pressed' , String ( card . dataset . filter === state . filter ) ) ;
} ) ;
}
function renderSortHeaders ( ) {
document . querySelectorAll ( 'th[aria-sort]' ) . forEach ( ( th ) => {
const key = th . querySelector ( '.sort' ) . dataset . sort ;
th . setAttribute ( 'aria-sort' ,
key === state . sort . key ? ( state . sort . dir === 1 ? 'ascending' : 'descending' ) : 'none' ) ;
} ) ;
}
function render ( ) {
const rows = visibleJobs ( ) ;
dom . tbody . replaceChildren ( ... rows . map ( renderRow ) ) ;
dom . empty . hidden = rows . length > 0 ;
renderSummary ( ) ;
renderSortHeaders ( ) ;
}
// ---------- Data loading ----------
let inFlight = null ;
let pollTimer = null ;
function refresh ( ) {
if ( inFlight ) return inFlight ; // de-dupe overlapping manual + scheduled refreshes
dom . refreshBtn . disabled = true ;
inFlight = ( async ( ) => {
try {
const data = await api . listJobs ( ) ;
state . jobs = data . jobs . map ( normalizeJob ) ;
state . lastSuccess = new Date ( ) ;
dom . errorBanner . hidden = true ;
dom . lastUpdated . textContent = ` Updated ${ formatTime ( state . lastSuccess ) } ` ;
} catch ( err ) {
// Keep showing stale data, but make it obvious it's stale
dom . errorBanner . textContent = state . lastSuccess
? ` Couldn't refresh ( ${ err . message } ). Showing data from ${ formatTime ( state . lastSuccess ) } . `
: ` Couldn't load backup status ( ${ err . message } ). ` ;
dom . errorBanner . hidden = false ;
dom . lastUpdated . textContent = 'Update failed' ;
console . error ( err ) ;
} finally {
render ( ) ;
dom . refreshBtn . disabled = false ;
inFlight = null ;
}
} ) ( ) ;
return inFlight ;
}
function schedulePoll ( ) {
clearTimeout ( pollTimer ) ;
pollTimer = setTimeout ( async ( ) => {
await refresh ( ) ;
schedulePoll ( ) ;
} , CONFIG . refreshIntervalMs ) ;
}
// Don't poll from background tabs (e.g. a wall display that's been switched away); catch up on return
document . addEventListener ( 'visibilitychange' , ( ) => {
if ( document . hidden ) clearTimeout ( pollTimer ) ;
else refresh ( ) . then ( schedulePoll ) ;
} ) ;
// ---------- Detail modal ----------
// Every async result checks reqId first, so a slow response for job A can never paint over job B.
const modal = {
reqId : 0 ,
job : null ,
detail : null ,
runs : null , // null = loading, [] = none
runsError : null ,
dayFilter : null , // local YYYY-MM-DD, or null for all days
selectedRunId : null , // null = the job's latest finished run (from the detail call)
runMessage : null , // { status, text, startedAt } for the selected run
runMessageError : null ,
} ;
// Colour of a day cell: the worst thing that happened that day
const DAY _SEVERITY = [ 'failed' , 'warning' , 'unknown' , 'running' , 'success' ] ;
const pad2 = ( n ) => String ( n ) . padStart ( 2 , '0' ) ;
const localDayKey = ( d ) => ` ${ d . getFullYear ( ) } - ${ pad2 ( d . getMonth ( ) + 1 ) } - ${ pad2 ( d . getDate ( ) ) } ` ;
const dayLabelFmt = new Intl . DateTimeFormat ( undefined , { weekday : 'short' , month : 'short' , day : 'numeric' } ) ;
const dayTickFmt = new Intl . DateTimeFormat ( undefined , { month : 'short' , day : 'numeric' } ) ;
function normalizeRun ( r ) {
return {
id : String ( r . id ) ,
status : normalizeStatus ( r . status ) ,
startedAt : parseDate ( r . started _at ) ,
finishedAt : parseDate ( r . finished _at ) ,
duration : r . duration _seconds ,
size : r . size _bytes ,
} ;
}
function buildDays ( runs , count ) {
const today = new Date ( ) ;
today . setHours ( 0 , 0 , 0 , 0 ) ;
const days = [ ] ;
for ( let i = count - 1 ; i >= 0 ; i -- ) {
const date = new Date ( today ) ;
date . setDate ( date . getDate ( ) - i ) ;
days . push ( { key : localDayKey ( date ) , date , counts : { } , total : 0 } ) ;
}
const byKey = new Map ( days . map ( ( d ) => [ d . key , d ] ) ) ;
for ( const run of runs ) {
const day = byKey . get ( localDayKey ( run . startedAt ) ) ;
if ( ! day ) continue ;
day . counts [ run . status ] = ( day . counts [ run . status ] ? ? 0 ) + 1 ;
day . total ++ ;
}
for ( const day of days ) day . status = day . total ? DAY _SEVERITY . find ( ( s ) => day . counts [ s ] ) : 'none' ;
return days ;
}
function describeCounts ( counts ) {
return DAY _SEVERITY . filter ( ( s ) => counts [ s ] )
. map ( ( s ) => ` ${ counts [ s ] } ${ STATUS [ s ] . label . toLowerCase ( ) } ` )
. join ( ', ' ) ;
}
function detailRow ( label , value ) {
if ( value == null || value === '' ) return [ ] ;
return [ el ( 'dt' , { } , label ) , el ( 'dd' , { } , value ) ] ;
}
function renderSummarySection ( ) {
const { job , detail } = modal ;
const status = detail ? normalizeStatus ( detail . status ) : job . status ;
const runningSince = detail ? parseDate ( detail . running _since ) : job . runningSince ;
const lastStatus = detail ? . last _status ? normalizeStatus ( detail . last _status ) : null ;
dom . detailTitle . textContent = detail ? . job _name ? ? job . name ;
dom . detailSubtitle . textContent = detail ? . company _name ? ? job . company ;
return el ( 'section' , { } ,
el ( 'div' , { class : 'detail-status' } ,
statusBadge ( status ) ,
runningSince ? el ( 'span' , { class : 'muted' } , ` since ${ formatDateTime ( runningSince ) } ` ) : null ) ,
el ( 'dl' , { class : 'detail-grid' } ,
// While a job is running, its "last run" is the previous one — say how that went
status === 'running' && lastStatus ? detailRow ( 'Previous result' , STATUS [ lastStatus ] . label ) : [ ] ,
detailRow ( 'Last finished' , formatDateTime ( detail ? parseDate ( detail . last _run _at ) : job . lastRun ) ) ,
detailRow ( 'Next scheduled' , formatDateTime ( detail ? parseDate ( detail . next _run _at ) : job . nextRun ) ) ,
detailRow ( 'Duration' , formatDuration ( detail ? . duration _seconds ) ) ,
detailRow ( 'Size' , formatBytes ( detail ? . size _bytes ) ) ,
detailRow ( 'Source' , detail ? . source _system ) ) ) ;
}
function renderHistorySection ( ) {
const section = el ( 'section' , { id : 'detail-history' } ,
el ( 'h3' , { class : 'detail-section-title' } , ` Last ${ CONFIG . historyDays } days ` ) ) ;
if ( modal . runsError ) {
section . append ( el ( 'p' , { class : 'banner banner--error' } , ` Couldn't load run history: ${ modal . runsError } ` ) ) ;
return section ;
}
if ( ! modal . runs ) {
section . append ( el ( 'p' , { class : 'muted' } , 'Loading history…' ) ) ;
return section ;
}
const days = buildDays ( modal . runs , CONFIG . historyDays ) ;
const finished = modal . runs . filter ( ( r ) => r . finishedAt ) ;
const ok = finished . filter ( ( r ) => r . status === 'success' ) . length ;
const totals = { } ;
for ( const r of modal . runs ) totals [ r . status ] = ( totals [ r . status ] ? ? 0 ) + 1 ;
section . append (
el ( 'p' , { class : 'history-stats' } ,
modal . runs . length
? ` ${ modal . runs . length } runs · ${ finished . length ? Math . round ( ( ok / finished . length ) * 100 ) : 0 } % successful · ${ describeCounts ( totals ) } `
: 'No runs recorded in this period.' ) ,
el ( 'div' , { class : 'day-strip' , role : 'group' , 'aria-label' : 'Daily results, oldest to newest. Select a day to filter runs.' } ,
days . map ( ( d ) => {
const label = ` ${ dayLabelFmt . format ( d . date ) } : ${ d . total ? describeCounts ( d . counts ) : 'no runs' } ` ;
return el ( 'button' , {
type : 'button' ,
class : ` day day-- ${ d . status } ` ,
title : label ,
'aria-label' : label ,
'aria-pressed' : String ( modal . dayFilter === d . key ) ,
disabled : ! d . total ,
dataset : { day : d . key } ,
} ) ;
} ) ) ,
el ( 'div' , { class : 'day-strip__ticks muted' , 'aria-hidden' : 'true' } ,
el ( 'span' , { } , dayTickFmt . format ( days [ 0 ] . date ) ) ,
el ( 'span' , { } , 'Today' ) ) ,
) ;
const rows = modal . dayFilter
? modal . runs . filter ( ( r ) => localDayKey ( r . startedAt ) === modal . dayFilter )
: modal . runs ;
if ( ! rows . length ) return section ;
if ( modal . dayFilter ) {
const date = days . find ( ( d ) => d . key === modal . dayFilter ) ? . date ;
section . append ( el ( 'p' , { class : 'muted history-filter' } ,
` Showing ${ dayLabelFmt . format ( date ) } · ` ,
el ( 'button' , { type : 'button' , class : 'link' , dataset : { day : modal . dayFilter } } , 'Show all days' ) ) ) ;
}
section . append ( el ( 'div' , { class : 'runs-wrap' } ,
el ( 'table' , { class : 'runs' } ,
el ( 'thead' , { } , el ( 'tr' , { } ,
[ 'Status' , 'Started' , 'Finished' , 'Duration' , 'Size' ] . map ( ( h ) => el ( 'th' , { scope : 'col' } , h ) ) ) ) ,
el ( 'tbody' , { } , rows . map ( ( r ) => el ( 'tr' , { class : r . id === modal . selectedRunId ? 'is-selected' : null } ,
el ( 'td' , { } , statusBadge ( r . status , { asButton : true , dataset : { runId : r . id } } ) ) ,
el ( 'td' , { } , formatDateTime ( r . startedAt ) ) ,
el ( 'td' , { } , r . finishedAt ? formatDateTime ( r . finishedAt ) : el ( 'span' , { class : 'muted' } , 'In progress' ) ) ,
el ( 'td' , { } , formatDuration ( r . duration ) ? ? '—' ) ,
el ( 'td' , { } , formatBytes ( r . size ) ? ? '—' ) ) ) ) ) ) ) ;
return section ;
}
function renderMessageSection ( ) {
const { detail , job } = modal ;
let status ;
let text ;
let heading = 'Message' ;
if ( modal . selectedRunId ) {
const run = modal . runs ? . find ( ( r ) => r . id === modal . selectedRunId ) ;
heading = ` Message · run started ${ formatDateTime ( run ? . startedAt ) } ` ;
status = run ? . status ? ? 'unknown' ;
text = modal . runMessageError
? ` Couldn't load this run: ${ modal . runMessageError } `
: modal . runMessage ? . text ? ? 'Loading…' ;
} else {
status = detail ? . last _status ? normalizeStatus ( detail . last _status ) : job . status ;
text = detail ? . message ? ? job . message ;
}
const extras = ! modal . selectedRunId && detail ? . details && typeof detail . details === 'object'
? Object . entries ( detail . details ) . flatMap ( ( [ k , v ] ) =>
detailRow ( k . replace ( /_/g , ' ' ) , typeof v === 'object' ? JSON . stringify ( v ) : String ( v ) ) )
: modal . runMessage ? . extras ? ? [ ] ;
return el ( 'section' , { id : 'detail-message' } ,
el ( 'h3' , { class : 'detail-section-title' } , heading ) ,
el ( 'pre' , { class : ` message message-- ${ status } ` } , text || 'No message reported.' ) ,
extras . length ? el ( 'h3' , { class : 'detail-section-title' } , 'Additional details' ) : null ,
extras . length ? el ( 'dl' , { class : 'detail-grid' } , extras ) : null ) ;
}
function renderModal ( { loading = false , error = null } = { } ) {
dom . detailBody . replaceChildren ( ... [
renderSummarySection ( ) ,
loading ? el ( 'p' , { class : 'muted' } , 'Loading full details…' ) : null ,
error ? el ( 'p' , { class : 'banner banner--error' } , ` Couldn't load full details: ${ error } ` ) : null ,
renderMessageSection ( ) ,
renderHistorySection ( ) ,
] . filter ( Boolean ) ) ;
}
// Re-render just the history section, keeping the runs table's scroll position
function renderHistory ( ) {
const wrap = dom . detailBody . querySelector ( '.runs-wrap' ) ;
const scrollTop = wrap ? . scrollTop ? ? 0 ;
dom . detailBody . querySelector ( '#detail-history' ) ? . replaceWith ( renderHistorySection ( ) ) ;
const newWrap = dom . detailBody . querySelector ( '.runs-wrap' ) ;
if ( newWrap ) newWrap . scrollTop = scrollTop ;
}
function renderMessage ( ) {
dom . detailBody . querySelector ( '#detail-message' ) ? . replaceWith ( renderMessageSection ( ) ) ;
}
async function openDetail ( jobId ) {
const job = state . jobs . find ( ( j ) => j . id === jobId ) ;
if ( ! job ) return ;
const reqId = ++ modal . reqId ;
Object . assign ( modal , {
job , detail : null , runs : null , runsError : null , dayFilter : null ,
selectedRunId : null , runMessage : null , runMessageError : null ,
} ) ;
renderModal ( { loading : true } ) ; // show what we have immediately
if ( ! dom . dialog . open ) dom . dialog . showModal ( ) ;
const current = ( ) => reqId === modal . reqId && dom . dialog . open ;
const detailP = api . getJob ( jobId ) . then (
( detail ) => { if ( current ( ) ) { modal . detail = detail ; renderModal ( ) ; } } ,
( err ) => { if ( current ( ) ) renderModal ( { error : err . message } ) ; } ,
) ;
const runsP = api . listRuns ( jobId , CONFIG . historyDays ) . then (
( data ) => {
if ( ! current ( ) ) return ;
modal . runs = ( data . runs ? ? [ ] ) . map ( normalizeRun ) . filter ( ( r ) => r . startedAt ) ;
renderHistory ( ) ;
} ,
( err ) => { if ( current ( ) ) { modal . runsError = err . message ; renderHistory ( ) ; } } ,
) ;
await Promise . all ( [ detailP , runsP ] ) ;
}
async function selectRun ( runId ) {
const reqId = modal . reqId ;
modal . selectedRunId = runId ;
modal . runMessage = null ;
modal . runMessageError = null ;
renderHistory ( ) ;
renderMessage ( ) ;
try {
const run = await api . getRun ( runId ) ;
if ( reqId !== modal . reqId || modal . selectedRunId !== runId ) return ;
modal . runMessage = {
text : run . message ,
extras : Object . entries ( run . details ? ? { } ) . flatMap ( ( [ k , v ] ) =>
detailRow ( k . replace ( /_/g , ' ' ) , typeof v === 'object' ? JSON . stringify ( v ) : String ( v ) ) ) ,
} ;
} catch ( err ) {
if ( reqId !== modal . reqId || modal . selectedRunId !== runId ) return ;
modal . runMessageError = err . message ;
}
renderMessage ( ) ;
dom . detailBody . querySelector ( '#detail-message' ) ? . scrollIntoView ( { block : 'nearest' , behavior : 'smooth' } ) ;
}
// ---------- Events ----------
dom . tbody . addEventListener ( 'click' , ( e ) => {
const btn = e . target . closest ( 'button[data-job-id]' ) ;
if ( btn ) openDetail ( btn . dataset . jobId ) ;
} ) ;
dom . dialog . addEventListener ( 'click' , ( e ) => {
// Click on the backdrop (the dialog element itself, outside its content) or the close button
if ( e . target === dom . dialog || e . target . closest ( '[data-close]' ) ) dom . dialog . close ( ) ;
} ) ;
dom . detailBody . addEventListener ( 'click' , ( e ) => {
const day = e . target . closest ( 'button[data-day]' ) ;
if ( day ) {
modal . dayFilter = modal . dayFilter === day . dataset . day ? null : day . dataset . day ;
renderHistory ( ) ;
return ;
}
const run = e . target . closest ( 'button[data-run-id]' ) ;
if ( run ) selectRun ( run . dataset . runId ) ;
} ) ;
document . querySelector ( '.summary' ) . addEventListener ( 'click' , ( e ) => {
const card = e . target . closest ( '.summary-card' ) ;
if ( ! card ) return ;
state . filter = card . dataset . filter ;
render ( ) ;
} ) ;
document . querySelector ( '.jobs thead' ) . addEventListener ( 'click' , ( e ) => {
const btn = e . target . closest ( '.sort' ) ;
if ( ! btn ) return ;
const key = btn . dataset . sort ;
state . sort = state . sort . key === key ? { key , dir : - state . sort . dir } : { key , dir : 1 } ;
render ( ) ;
} ) ;
dom . search . addEventListener ( 'input' , ( ) => {
state . search = dom . search . value ;
render ( ) ;
} ) ;
dom . refreshBtn . addEventListener ( 'click' , ( ) => refresh ( ) . then ( schedulePoll ) ) ;
// ---------- Boot ----------
refresh ( ) . then ( schedulePoll ) ;