2026-05-22 10:05:56 -04:00
// screens-jobs.jsx
2026-05-22 08:19:01 -04:00
2026-05-23 14:52:04 -04:00
// Pick the most-meaningful timestamp + label for a job's current state.
// Returns { label, iso } — caller renders "<label> <relative-time>" with
// the full ISO as a tooltip.
function _jobTimeFor ( job ) {
if ( job . status === 'done' && job . completed _at ) return { label : 'done' , iso : job . completed _at } ;
if ( job . status === 'failed' && job . failed _at ) return { label : 'failed' , iso : job . failed _at } ;
if ( job . status === 'running' && job . started _at ) return { label : 'started' , iso : job . started _at } ;
if ( job . created _at ) return { label : 'queued' , iso : job . created _at } ;
return null ;
}
function _fmtAbsolute ( iso ) {
if ( ! iso ) return '' ;
try {
return new Date ( iso ) . toLocaleString ( undefined , {
month : 'short' , day : 'numeric' ,
hour : 'numeric' , minute : '2-digit' , second : '2-digit' ,
} ) ;
} catch { return iso ; }
}
2026-05-23 15:26:24 -04:00
// Compact clock for the inline jobs cell — "2:23 PM" if today,
// "May 22 · 2:23 PM" if a different day. Full datetime stays in the tooltip.
function _fmtCompact ( iso ) {
if ( ! iso ) return '' ;
try {
const d = new Date ( iso ) ;
const now = new Date ( ) ;
const sameDay = d . getFullYear ( ) === now . getFullYear ( )
&& d . getMonth ( ) === now . getMonth ( )
&& d . getDate ( ) === now . getDate ( ) ;
const time = d . toLocaleTimeString ( undefined , { hour : 'numeric' , minute : '2-digit' } ) ;
if ( sameDay ) return time ;
const date = d . toLocaleDateString ( undefined , { month : 'short' , day : 'numeric' } ) ;
return date + ' · ' + time ;
} catch { return iso ; }
}
2026-05-22 08:19:01 -04:00
function Jobs ( { navigate } ) {
2026-05-22 10:05:56 -04:00
const [ tab , setTab ] = React . useState ( 'all' ) ;
const [ jobs , setJobs ] = React . useState ( window . ZAMPP _DATA . JOBS ) ;
const [ lastFetch , setLastFetch ] = React . useState ( Date . now ( ) ) ;
2026-05-22 08:19:01 -04:00
2026-05-22 12:18:23 -04:00
const normalizeJob = ( j ) => {
const statusMap = { waiting : 'queued' , active : 'running' , completed : 'done' , failed : 'failed' } ;
2026-05-23 16:05:41 -04:00
const kindMap = { proxy : 'Proxy' , thumbnail : 'Thumbnail' , conform : 'Conform' , transcode : 'Transcode' , import : 'YouTube' } ;
2026-05-22 12:18:23 -04:00
const meta = j . metadata || { } ;
return {
... j ,
status : statusMap [ j . status ] || j . status ,
kind : kindMap [ j . type ] || j . type || 'Job' ,
asset : j . asset _name || meta . filename || '—' ,
eta : '—' ,
node : meta . node || '—' ,
priority : meta . priority || 'normal' ,
error : j . error || null ,
progress : j . progress || 0 ,
2026-05-22 10:05:56 -04:00
} ;
2026-05-22 12:18:23 -04:00
} ;
const refresh = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/jobs' )
. then ( raw => {
const norm = ( raw || [ ] ) . map ( normalizeJob ) ;
window . ZAMPP _DATA . JOBS = norm ;
setJobs ( norm ) ;
setLastFetch ( Date . now ( ) ) ;
} )
. catch ( ( ) => { } ) ;
} , [ ] ) ;
React . useEffect ( ( ) => {
const i = setInterval ( refresh , 5000 ) ;
2026-05-22 08:19:01 -04:00
return ( ) => clearInterval ( i ) ;
2026-05-22 12:18:23 -04:00
} , [ refresh ] ) ;
const handleRetry = React . useCallback ( ( job ) => {
window . ZAMPP _API . fetch ( '/jobs/' + job . id + '/retry' , { method : 'POST' } )
. then ( ( ) => refresh ( ) )
. catch ( e => alert ( 'Retry failed: ' + e . message ) ) ;
} , [ refresh ] ) ;
2026-05-23 16:54:05 -04:00
// One handler covers cancel (running) AND delete (queued / done / failed).
// BullMQ's job.remove() — what the API calls — works on any state, so a
// stalled-active job (worker died mid-process, holding a concurrency slot)
// gets yanked and the next queued job runs. mode just changes the prompt
// copy so the operator knows what they're doing.
const handleDelete = React . useCallback ( ( job , mode ) => {
const msg = mode === 'cancel'
? 'Cancel this running ' + job . kind + ' job?\n\nThe worker may run a few seconds longer in the background, but its result will be discarded and the queue slot frees up immediately.'
: 'Remove this ' + job . status + ' ' + job . kind + ' job from the queue?' ;
if ( ! window . confirm ( msg ) ) return ;
2026-05-22 12:18:23 -04:00
window . ZAMPP _API . fetch ( '/jobs/' + job . id , { method : 'DELETE' } )
. then ( ( ) => setJobs ( prev => prev . filter ( j => j . id !== job . id ) ) )
2026-05-23 16:54:05 -04:00
. catch ( e => alert ( ( mode === 'cancel' ? 'Cancel' : 'Delete' ) + ' failed: ' + e . message ) ) ;
2026-05-22 08:19:01 -04:00
} , [ ] ) ;
2026-05-23 00:08:59 -04:00
// Retry every failed job at once. Useful after a transient infra issue
// (S3 outage, hung worker) — one click per job is painful with 20+ failures.
const handleRetryAll = React . useCallback ( ( ) => {
const failedJobs = jobs . filter ( j => j . status === 'failed' ) ;
if ( failedJobs . length === 0 ) return ;
if ( ! window . confirm ( ` Re-queue all ${ failedJobs . length } failed jobs? ` ) ) return ;
Promise . allSettled (
failedJobs . map ( j => window . ZAMPP _API . fetch ( '/jobs/' + j . id + '/retry' , { method : 'POST' } ) )
) . then ( refresh ) ;
} , [ jobs , refresh ] ) ;
2026-05-22 08:19:01 -04:00
const counts = {
2026-05-22 10:05:56 -04:00
all : jobs . length ,
running : jobs . filter ( j => j . status === 'running' ) . length ,
queued : jobs . filter ( j => j . status === 'queued' ) . length ,
done : jobs . filter ( j => j . status === 'done' ) . length ,
failed : jobs . filter ( j => j . status === 'failed' ) . length ,
2026-05-22 08:19:01 -04:00
} ;
2026-05-22 10:05:56 -04:00
const filtered = tab === 'all' ? jobs : jobs . filter ( j => j . status === tab ) ;
2026-05-22 08:19:01 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Jobs < / h1 >
2026-05-22 10:05:56 -04:00
< span className = "subtitle" > Proxy generation , transcoding , and processing queue < / span >
2026-05-22 08:19:01 -04:00
< div className = "spacer" / >
2026-05-23 00:08:59 -04:00
{ counts . failed > 0 && (
< button className = "btn ghost sm" onClick = { handleRetryAll } title = { ` Retry all ${ counts . failed } failed jobs ` } >
< Icon name = "refresh" / > Retry all failed
< / button >
) }
2026-05-22 12:18:23 -04:00
< button className = "btn ghost sm" onClick = { refresh } >
2026-05-22 10:05:56 -04:00
< Icon name = "refresh" / > Refresh
< / button >
2026-05-22 08:19:01 -04:00
< / div >
< div className = "page-body" >
< div className = "jobs-stats" >
< div className = "stat-card" >
2026-05-22 10:05:56 -04:00
< div className = "label" > Running < / div >
< div className = "value" > { counts . running } < / div >
< div className = "delta" > { counts . queued } queued < / div >
2026-05-22 08:19:01 -04:00
< / div >
< div className = "stat-card" >
2026-05-22 10:05:56 -04:00
< div className = "label" > Completed < / div >
< div className = "value" > { counts . done } < / div >
< div className = "delta" > Total done < / div >
2026-05-22 08:19:01 -04:00
< / div >
< div className = "stat-card" >
2026-05-22 10:05:56 -04:00
< div className = "label" > Failed < / div >
2026-05-22 08:19:01 -04:00
< div className = "value" > { counts . failed } < / div >
2026-05-22 10:05:56 -04:00
< div className = "delta" style = { { color : counts . failed > 0 ? 'var(--warning)' : '' } } >
{ counts . failed > 0 ? 'Needs attention' : 'All clear' }
2026-05-22 08:19:01 -04:00
< / div >
< / div >
2026-05-22 10:05:56 -04:00
< div className = "stat-card" >
< div className = "label" > Total jobs < / div >
< div className = "value" > { counts . all } < / div >
< div className = "delta muted" style = { { fontSize : 10.5 } } > Updated { Math . round ( ( Date . now ( ) - lastFetch ) / 1000 ) } s ago < / div >
< / div >
2026-05-22 08:19:01 -04:00
< / div >
2026-05-22 10:05:56 -04:00
< div className = "tab-group" style = { { marginTop : 20 , width : 'fit-content' } } >
2026-05-22 08:19:01 -04:00
{ [
2026-05-22 10:05:56 -04:00
{ id : 'all' , label : 'All · ' + counts . all } ,
{ id : 'running' , label : 'Running · ' + counts . running } ,
{ id : 'queued' , label : 'Queued · ' + counts . queued } ,
{ id : 'done' , label : 'Done · ' + counts . done } ,
{ id : 'failed' , label : 'Failed · ' + counts . failed } ,
2026-05-22 08:19:01 -04:00
] . map ( t => (
2026-05-22 10:05:56 -04:00
< button key = { t . id } className = { tab === t . id ? 'active' : '' } onClick = { ( ) => setTab ( t . id ) } > { t . label } < / button >
2026-05-22 08:19:01 -04:00
) ) }
< / div >
< div className = "panel" style = { { marginTop : 12 } } >
< div className = "job-row head" >
2026-05-23 14:52:04 -04:00
< div > < / div > < div > Job < / div > < div > Asset < / div > < div > Node < / div > < div > Progress < / div > < div > Time < / div > < div > Priority < / div > < div > < / div >
2026-05-22 08:19:01 -04:00
< / div >
2026-05-22 10:05:56 -04:00
{ filtered . length === 0
? < div style = { { padding : '24px' , textAlign : 'center' , color : 'var(--text-3)' } } > No jobs in this category . < / div >
2026-05-22 12:18:23 -04:00
: filtered . map ( j => < JobRow key = { j . id } job = { j } onRetry = { handleRetry } onDelete = { handleDelete } / > ) }
2026-05-22 08:19:01 -04:00
< / div >
< / div >
< / div >
) ;
}
2026-05-22 12:18:23 -04:00
function JobRow ( { job , onRetry , onDelete } ) {
2026-05-22 10:05:56 -04:00
const iconMap = { Proxy : 'proxy' , Transcode : 'film' , Thumbnail : 'image' , Conform : 'layers' } ;
2026-05-22 08:19:01 -04:00
return (
< div className = "job-row" >
< div > < StatusDot status = { job . status } / > < / div >
2026-05-22 10:05:56 -04:00
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
< Icon name = { iconMap [ job . kind ] || 'jobs' } size = { 13 } style = { { color : 'var(--text-3)' } } / >
2026-05-22 08:19:01 -04:00
< span style = { { fontWeight : 500 } } > { job . kind } < / span >
< / div >
2026-05-22 10:05:56 -04:00
< div style = { { overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' , color : 'var(--text-2)' } } > { job . asset } < / div >
< div className = "mono" style = { { fontSize : 11.5 , color : 'var(--text-3)' } } > { job . node } < / div >
2026-05-22 08:19:01 -04:00
< div >
2026-05-22 10:05:56 -04:00
{ job . status === 'running' && (
2026-05-22 08:19:01 -04:00
< div className = "job-progress-wrap" >
2026-05-22 10:05:56 -04:00
< div className = "job-progress-bar" > < div className = "job-progress-fill" style = { { width : job . progress + '%' } } / > < / div >
< span className = "mono" style = { { fontSize : 10.5 , color : 'var(--text-3)' , minWidth : 32 , textAlign : 'right' } } > { Math . round ( job . progress ) } % < / span >
2026-05-22 08:19:01 -04:00
< / div >
) }
2026-05-22 10:05:56 -04:00
{ job . status === 'done' && < span className = "badge success" style = { { background : 'transparent' , padding : 0 } } > < Icon name = "check" size = { 12 } / > Complete < / span > }
{ job . status === 'queued' && < span style = { { fontSize : 12 , color : 'var(--text-3)' } } > Waiting … < / span > }
2026-05-23 00:08:59 -04:00
{ job . status === 'failed' && (
< span title = { job . error || 'Failed' }
style = { { fontSize : 12 , color : 'var(--danger)' , display : 'flex' , alignItems : 'center' , gap : 4 , overflow : 'hidden' } } >
< Icon name = "alert" size = { 12 } / >
< span style = { { overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' , minWidth : 0 } } >
{ ( job . error || 'Failed' ) . slice ( 0 , 120 ) }
< / span >
< / span >
) }
2026-05-22 08:19:01 -04:00
< / div >
2026-05-23 14:52:04 -04:00
< div className = "mono" style = { { fontSize : 11.5 , color : 'var(--text-3)' , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } }
title = { ( ( ) => { const t = _jobTimeFor ( job ) ; return t ? t . label + ' at ' + _fmtAbsolute ( t . iso ) : '' ; } ) ( ) } >
{ ( ( ) => {
const t = _jobTimeFor ( job ) ;
if ( ! t ) return '—' ;
2026-05-23 15:26:24 -04:00
// Terminal states (done/failed) anchor on the absolute clock so the
// operator can correlate with logs; queued/running show relative
// since it's a moving target.
if ( job . status === 'done' || job . status === 'failed' ) {
return t . label + ' ' + _fmtCompact ( t . iso ) + ' · ' + window . ZAMPP _API . fmtRelative ( t . iso ) ;
}
2026-05-23 14:52:04 -04:00
return t . label + ' ' + window . ZAMPP _API . fmtRelative ( t . iso ) ;
} ) ( ) }
< / div >
2026-05-22 10:05:56 -04:00
< div > < span className = { 'badge ' + ( job . priority === 'high' ? 'warning' : 'outline' ) } > { job . priority } < / span > < / div >
2026-05-23 16:54:05 -04:00
< div style = { { display : 'flex' , gap : 4 , justifyContent : 'flex-end' } } >
2026-05-22 12:18:23 -04:00
{ job . status === 'failed' && (
< button className = "btn ghost sm" onClick = { ( ) => onRetry ( job ) } > < Icon name = "refresh" / > Retry < / button >
) }
2026-05-23 16:54:05 -04:00
{ job . status === 'running' && (
/ * C a n c e l a s t a l l e d - a c t i v e j o b — f r e e s t h e B u l l M Q c o n c u r r e n c y s l o t
so anything queued behind it can run . The worker may finish in
the background but its result is discarded . * /
< button className = "btn ghost sm" onClick = { ( ) => onDelete ( job , 'cancel' ) }
style = { { color : 'var(--danger)' } } title = "Cancel this running job and free its queue slot" >
< Icon name = "x" / > Cancel
< / button >
) }
{ ( job . status === 'queued' || job . status === 'done' || job . status === 'failed' ) && (
< button className = "icon-btn" title = "Remove job from the queue"
onClick = { ( ) => onDelete ( job , 'delete' ) } > < Icon name = "x" / > < / button >
2026-05-22 12:18:23 -04:00
) }
2026-05-22 08:19:01 -04:00
< / div >
< / div >
) ;
}
window . Jobs = Jobs ;