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' ) && (
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
< button className = "icon-btn" aria - label = "Remove job from the queue" title = "Remove job from the queue"
2026-05-23 16:54:05 -04:00
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 ;