ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// screens-ingest.jsx - Upload, Recorders, Capture, Monitors
2026-05-22 08:20:15 -04:00
2026-05-22 11:10:00 -04:00
/* ===== Upload helpers ===== */
const _SIMPLE _MAX = 50 * 1024 * 1024 ; // 50 MB → simple upload
const _PART _SIZE = 10 * 1024 * 1024 ; // 10 MB chunks for multipart
function _xhrPost ( url , formData , onProgress ) {
return new Promise ( ( resolve , reject ) => {
const xhr = new XMLHttpRequest ( ) ;
xhr . withCredentials = true ;
xhr . upload . onprogress = ( e ) => { if ( e . lengthComputable ) onProgress ( e . loaded , e . total ) ; } ;
xhr . onload = ( ) => {
if ( xhr . status >= 200 && xhr . status < 300 ) {
try { resolve ( JSON . parse ( xhr . responseText ) ) ; } catch { resolve ( { } ) ; }
} else {
let msg = xhr . status + ' ' + xhr . statusText ;
try { const j = JSON . parse ( xhr . responseText ) ; msg = j . error || j . message || msg ; } catch { }
reject ( new Error ( msg ) ) ;
}
} ;
xhr . onerror = ( ) => reject ( new Error ( 'Network error' ) ) ;
xhr . open ( 'POST' , url ) ;
2026-05-27 15:42:42 -04:00
xhr . setRequestHeader ( 'X-Requested-With' , 'dragonflight-ui' ) ;
2026-05-22 11:10:00 -04:00
xhr . send ( formData ) ;
} ) ;
}
async function _uploadFile ( file , projectId , onProgress ) {
const mime = file . type || 'application/octet-stream' ;
if ( file . size <= _SIMPLE _MAX ) {
const fd = new FormData ( ) ;
fd . append ( 'file' , file , file . name ) ;
fd . append ( 'filename' , file . name ) ;
fd . append ( 'projectId' , projectId ) ;
fd . append ( 'contentType' , mime ) ;
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
return _xhrPost ( ( window . ZAMPP _API _PREFIX || '/api/v1' ) + '/upload/simple' , fd ,
2026-05-22 11:10:00 -04:00
( loaded , total ) => onProgress ( Math . round ( ( loaded / total ) * 100 ) ) ) ;
}
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// - Multipart -
2026-05-22 11:10:00 -04:00
const init = await window . ZAMPP _API . fetch ( '/upload/init' , {
method : 'POST' ,
body : JSON . stringify ( { filename : file . name , fileSize : file . size , contentType : mime , projectId } ) ,
} ) ;
const { assetId , uploadId , key } = init ;
const totalParts = Math . ceil ( file . size / _PART _SIZE ) ;
const parts = [ ] ;
for ( let i = 0 ; i < totalParts ; i ++ ) {
const chunk = file . slice ( i * _PART _SIZE , Math . min ( ( i + 1 ) * _PART _SIZE , file . size ) ) ;
const fd = new FormData ( ) ;
fd . append ( 'file' , chunk , file . name ) ;
fd . append ( 'uploadId' , uploadId ) ;
fd . append ( 'key' , key ) ;
fd . append ( 'partNumber' , String ( i + 1 ) ) ;
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
const partRes = await _xhrPost ( ( window . ZAMPP _API _PREFIX || '/api/v1' ) + '/upload/part' , fd ,
2026-05-22 11:10:00 -04:00
( loaded , total ) => onProgress ( Math . round ( ( ( i + loaded / total ) / totalParts ) * 100 ) ) ) ;
parts . push ( { PartNumber : i + 1 , ETag : partRes . etag || partRes . ETag } ) ;
}
await window . ZAMPP _API . fetch ( '/upload/complete' , {
method : 'POST' ,
body : JSON . stringify ( { uploadId , key , assetId , parts } ) ,
} ) ;
return { id : assetId } ;
}
2026-05-22 10:07:13 -04:00
/* ===== Upload ===== */
2026-05-22 08:20:15 -04:00
function Upload ( { navigate } ) {
feat(comments): persistent frame-anchored comments on asset detail
- migration 010: asset_comments table (id, asset_id, user_id, body,
frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
PATCH/DELETE with author join (display_name + initials), nullable
user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
empty-state copy ('Add one below to mark a frame'), tooltips on
the timestamp and resolved buttons
Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
2026-05-23 00:21:11 -04:00
const PROJECTS = window . ZAMPP _DATA ? . PROJECTS || [ ] ;
2026-05-22 10:07:13 -04:00
const [ files , setFiles ] = React . useState ( [ ] ) ;
2026-05-22 11:10:00 -04:00
const [ projectId , setProjectId ] = React . useState ( PROJECTS [ 0 ] ? . id || '' ) ;
2026-05-22 08:20:15 -04:00
2026-05-22 11:10:00 -04:00
const updateFile = React . useCallback ( ( id , patch ) => {
setFiles ( prev => prev . map ( f => f . id === id ? { ... f , ... patch } : f ) ) ;
2026-05-22 08:20:15 -04:00
} , [ ] ) ;
2026-05-22 11:10:00 -04:00
const startUpload = React . useCallback ( ( entry , pid ) => {
_uploadFile ( entry . file , pid , ( pct ) => updateFile ( entry . id , { progress : pct } ) )
2026-05-24 20:36:04 -04:00
. then ( ( ) => {
updateFile ( entry . id , { status : 'done' , progress : 100 } ) ;
window . dispatchEvent ( new CustomEvent ( 'df:assets-changed' ) ) ;
} )
2026-05-22 11:10:00 -04:00
. catch ( e => updateFile ( entry . id , { status : 'error' , progress : 0 , error : e . message } ) ) ;
} , [ updateFile ] ) ;
const handleDrop = React . useCallback ( ( e ) => {
2026-05-22 10:07:13 -04:00
e . preventDefault ( ) ;
const dropped = Array . from ( e . dataTransfer ? e . dataTransfer . files : e . target . files ) ;
2026-05-22 11:10:00 -04:00
const pid = projectId || PROJECTS [ 0 ] ? . id || '' ;
const newEntries = dropped . map ( ( f , i ) => ( {
id : Date . now ( ) + i ,
name : f . name ,
2026-05-22 10:07:13 -04:00
size : window . ZAMPP _API . fmtSize ( f . size ) ,
2026-05-22 11:10:00 -04:00
file : f ,
progress : 0 ,
status : 'uploading' ,
error : null ,
2026-05-22 10:07:13 -04:00
} ) ) ;
2026-05-22 11:10:00 -04:00
setFiles ( prev => [ ... prev , ... newEntries ] ) ;
newEntries . forEach ( entry => startUpload ( entry , pid ) ) ;
} , [ projectId , startUpload ] ) ;
2026-05-22 10:07:13 -04:00
2026-05-22 08:20:15 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Upload < / h1 >
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
< span className = "subtitle" > Drop video , audio , or stills : we proxy and index automatically . < / span >
2026-05-22 08:20:15 -04:00
< / div >
< div className = "page-body" >
2026-05-22 10:07:13 -04:00
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 , marginBottom : 20 } } >
2026-05-22 08:20:15 -04:00
< div >
< label className = "field-label" > Project < / label >
2026-05-22 11:10:00 -04:00
< select className = "field-input" value = { projectId } onChange = { e => setProjectId ( e . target . value ) }
style = { { appearance : 'auto' } } >
{ PROJECTS . length === 0
? < option value = "" > No projects < / option >
: PROJECTS . map ( p => < option key = { p . id } value = { p . id } > { p . name } < / option > ) }
< / select >
2026-05-22 08:20:15 -04:00
< / div >
< / div >
2026-05-22 11:10:00 -04:00
< div className = "dropzone"
onDrop = { handleDrop }
onDragOver = { e => e . preventDefault ( ) }
onClick = { ( ) => {
const inp = document . createElement ( 'input' ) ;
inp . type = 'file' ; inp . multiple = true ;
inp . onchange = handleDrop ;
inp . click ( ) ;
} } >
2026-05-22 10:07:13 -04:00
< Icon name = "upload" size = { 32 } style = { { color : 'var(--text-3)' } } / >
2026-05-22 08:20:15 -04:00
< div style = { { fontSize : 15 , fontWeight : 500 } } > Drop files here or click to browse < / div >
2026-05-22 10:07:13 -04:00
< div className = "muted" style = { { fontSize : 12.5 } } > Video , audio , and image files < / div >
2026-05-22 08:20:15 -04:00
< div className = "dropzone-formats" >
2026-05-22 11:10:00 -04:00
{ [ 'MOV' , 'MP4' , 'MXF' , 'ProRes' , 'DNxHR' , 'WAV' , 'AIFF' ] . map ( f =>
< span key = { f } className = "badge outline" > { f } < / span > ) }
2026-05-22 08:20:15 -04:00
< / div >
< / div >
2026-05-22 10:07:13 -04:00
{ files . length > 0 && (
< div style = { { marginTop : 24 } } >
< div style = { { fontSize : 13 , fontWeight : 600 , marginBottom : 12 , display : 'flex' , alignItems : 'center' , gap : 8 } } >
Queue < span className = "badge neutral" > { files . length } < / span >
< span style = { { flex : 1 } } / >
2026-05-22 11:10:00 -04:00
< button className = "btn ghost sm" onClick = { ( ) => setFiles ( f => f . filter ( x => x . status === 'uploading' ) ) } > Clear done < / button >
2026-05-22 10:07:13 -04:00
< / div >
< div className = "panel" >
{ files . map ( f => (
< div key = { f . id } className = "upload-row" >
2026-05-22 11:10:00 -04:00
< Icon name = { f . name . match ( /\.(wav|aif|mp3|aiff)$/i ) ? 'audio' : 'video' } size = { 16 }
style = { { color : 'var(--text-3)' } } / >
2026-05-22 10:07:13 -04:00
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { display : 'flex' , gap : 8 , alignItems : 'center' } } >
< span style = { { fontWeight : 500 , fontSize : 12.5 } } > { f . name } < / span >
< span className = "muted" style = { { fontSize : 11 , fontFamily : 'var(--font-mono)' } } > { f . size } < / span >
< / div >
< div style = { { marginTop : 6 , height : 4 , background : 'var(--bg-3)' , borderRadius : 99 , overflow : 'hidden' } } >
2026-05-22 11:10:00 -04:00
< div style = { {
width : f . progress + '%' , height : '100%' ,
background : f . status === 'done' ? 'var(--success)' : f . status === 'error' ? 'var(--danger)' : 'var(--accent)' ,
transition : 'width 200ms' ,
} } / >
2026-05-22 10:07:13 -04:00
< / div >
2026-05-22 11:10:00 -04:00
{ f . status === 'error' && (
< div style = { { marginTop : 3 , fontSize : 11 , color : 'var(--danger)' } } > { f . error } < / div >
) }
2026-05-22 08:20:15 -04:00
< / div >
2026-05-22 11:10:00 -04:00
< span className = "mono" style = { {
fontSize : 11.5 , minWidth : 60 , textAlign : 'right' ,
color : f . status === 'done' ? 'var(--success)' : f . status === 'error' ? 'var(--danger)' : 'var(--text-3)' ,
} } >
{ f . status === 'done' ? '✓ done'
: f . status === 'error' ? '✗ failed'
: f . progress + '%' }
2026-05-22 10:07:13 -04:00
< / span >
2026-05-22 08:20:15 -04:00
< / div >
2026-05-22 10:07:13 -04:00
) ) }
< / div >
2026-05-22 08:20:15 -04:00
< / div >
2026-05-22 10:07:13 -04:00
) }
2026-05-22 08:20:15 -04:00
< / div >
< / div >
) ;
}
2026-05-23 16:05:41 -04:00
/* ===== YouTube importer ===== */
// Accept the same three URL shapes the API validates against.
const _YT _PATTERNS = [
/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i ,
/^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i ,
/^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i ,
] ;
function _looksLikeYouTube ( s ) {
return typeof s === 'string' && _YT _PATTERNS . some ( re => re . test ( s . trim ( ) ) ) ;
}
function YouTubeImport ( { navigate } ) {
const PROJECTS = window . ZAMPP _DATA ? . PROJECTS || [ ] ;
const [ projectId , setProjectId ] = React . useState ( PROJECTS [ 0 ] ? . id || '' ) ;
const [ url , setUrl ] = React . useState ( '' ) ;
const [ queue , setQueue ] = React . useState ( [ ] ) ; // { id, url, status, progress, title, error, assetId, jobId }
const [ submitting , setSubmitting ] = React . useState ( false ) ;
const valid = _looksLikeYouTube ( url ) ;
const updateRow = React . useCallback ( ( id , patch ) => {
setQueue ( prev => prev . map ( r => r . id === id ? { ... r , ... patch } : r ) ) ;
} , [ ] ) ;
// Poll the asset row to pick up the title once yt-dlp resolves it, and the
// proxy job's progress so the queue row reflects the full lifecycle, not
// just the import step.
const pollRow = React . useCallback ( ( row ) => {
if ( ! row . assetId ) return ;
let stopped = false ;
const tick = async ( ) => {
if ( stopped ) return ;
try {
const asset = await window . ZAMPP _API . fetch ( '/assets/' + row . assetId ) ;
const patch = { } ;
if ( asset . display _name && asset . display _name !== row . url ) patch . title = asset . display _name ;
if ( asset . status === 'ready' ) {
patch . status = 'done' ;
patch . progress = 100 ;
2026-05-24 20:36:04 -04:00
window . dispatchEvent ( new CustomEvent ( 'df:assets-changed' ) ) ;
2026-05-23 16:05:41 -04:00
} else if ( asset . status === 'error' ) {
patch . status = 'error' ;
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
patch . error = patch . error || 'Import failed: check the Jobs screen for details.' ;
2026-05-23 16:05:41 -04:00
} else if ( asset . status === 'processing' ) {
patch . status = 'processing' ;
2026-05-29 19:53:19 -04:00
} else if ( asset . status === 'ingesting' ) {
patch . status = 'downloading' ;
2026-05-23 16:05:41 -04:00
}
2026-05-29 19:53:19 -04:00
// While the import is still running, pull the live percentage from its
// BullMQ job so the bar reflects the actual yt-dlp download instead of
// sitting at 0 until the asset flips to ready. The worker emits 2..100
// across the download + S3 upload (job.updateProgress). If the job has
// already completed and been evicted, the fetch throws and we just fall
// back to the status-driven values above.
if ( row . jobId && asset . status !== 'ready' && asset . status !== 'error' ) {
try {
const job = await window . ZAMPP _API . fetch ( '/jobs/' + row . jobId ) ;
if ( typeof job . progress === 'number' ) patch . progress = job . progress ;
} catch { /* job evicted after completion — fall back to status */ }
}
2026-05-23 16:05:41 -04:00
if ( Object . keys ( patch ) . length ) updateRow ( row . id , patch ) ;
if ( asset . status === 'ready' || asset . status === 'error' ) return ;
} catch { /* ignore */ }
2026-05-29 19:53:19 -04:00
// Poll fairly briskly: the download phase is where the user wants to see
// the bar move, and a short clip can finish in a handful of seconds.
setTimeout ( tick , 2000 ) ;
2026-05-23 16:05:41 -04:00
} ;
tick ( ) ;
return ( ) => { stopped = true ; } ;
} , [ updateRow ] ) ;
const submit = React . useCallback ( async ( ) => {
if ( ! valid || ! projectId || submitting ) return ;
setSubmitting ( true ) ;
const rowId = Date . now ( ) ;
const row = {
id : rowId ,
url : url . trim ( ) ,
status : 'queued' ,
progress : 0 ,
title : '' ,
error : null ,
assetId : null ,
jobId : null ,
} ;
setQueue ( prev => [ row , ... prev ] ) ;
try {
const res = await window . ZAMPP _API . fetch ( '/imports/youtube' , {
method : 'POST' ,
body : JSON . stringify ( { url : row . url , projectId } ) ,
} ) ;
updateRow ( rowId , { assetId : res . assetId , jobId : res . jobId , status : 'downloading' } ) ;
pollRow ( { ... row , assetId : res . assetId , jobId : res . jobId } ) ;
setUrl ( '' ) ;
} catch ( e ) {
updateRow ( rowId , { status : 'error' , error : e . message || 'Failed to start import' } ) ;
} finally {
setSubmitting ( false ) ;
}
} , [ valid , projectId , submitting , url , updateRow , pollRow ] ) ;
return (
< div className = "page" >
< div className = "page-header" >
< h1 > YouTube < / h1 >
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
< span className = "subtitle" > Paste a link : we download and import the best available MP4 . < / span >
2026-05-23 16:05:41 -04:00
< / div >
< div className = "page-body" >
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 , marginBottom : 16 } } >
< div >
< label className = "field-label" > Project < / label >
< select className = "field-input" value = { projectId } onChange = { e => setProjectId ( e . target . value ) }
style = { { appearance : 'auto' } } >
{ PROJECTS . length === 0
? < option value = "" > No projects < / option >
: PROJECTS . map ( p => < option key = { p . id } value = { p . id } > { p . name } < / option > ) }
< / select >
< / div >
< / div >
< div className = "field" style = { { marginBottom : 8 } } >
< label className = "field-label" > YouTube URL < / label >
< div style = { { display : 'flex' , gap : 8 } } >
< input
className = "field-input mono"
value = { url }
onChange = { e => setUrl ( e . target . value ) }
onKeyDown = { e => { if ( e . key === 'Enter' && valid ) submit ( ) ; } }
placeholder = "https://www.youtube.com/watch?v=… or https://youtu.be/…"
style = { { flex : 1 } }
autoFocus
/ >
< button
className = "btn primary"
onClick = { submit }
disabled = { ! valid || ! projectId || submitting }
title = { ! valid ? 'Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)' : '' }
>
< Icon name = "download" / > Import
< / button >
< / div >
{ url && ! valid && (
< div style = { { fontSize : 11.5 , color : 'var(--danger)' , marginTop : 4 } } >
That doesn ' t look like a YouTube URL .
< / div >
) }
< div style = { { fontSize : 11.5 , color : 'var(--text-3)' , marginTop : 6 } } >
Only import videos you have rights to use . Private , age - gated , and members - only videos are not supported .
< / div >
< / div >
{ queue . length > 0 && (
< div style = { { marginTop : 20 } } >
< div style = { { fontSize : 13 , fontWeight : 600 , marginBottom : 12 , display : 'flex' , alignItems : 'center' , gap : 8 } } >
Queue < span className = "badge neutral" > { queue . length } < / span >
< span style = { { flex : 1 } } / >
< button className = "btn ghost sm" onClick = { ( ) => setQueue ( q => q . filter ( r => r . status !== 'done' && r . status !== 'error' ) ) } >
Clear finished
< / button >
< / div >
< div className = "panel" >
{ queue . map ( r => {
const statusColor =
r . status === 'done' ? 'var(--success)' :
r . status === 'error' ? 'var(--danger)' : 'var(--text-3)' ;
return (
< div key = { r . id } className = "upload-row" >
< Icon name = "link" size = { 16 } style = { { color : 'var(--text-3)' } } / >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { display : 'flex' , gap : 8 , alignItems : 'center' } } >
< span style = { { fontWeight : 500 , fontSize : 12.5 , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } } >
{ r . title || r . url }
< / span >
< / div >
{ r . title && (
< div className = "muted mono" style = { { fontSize : 10.5 , marginTop : 2 , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' } } title = { r . url } >
{ r . url }
< / div >
) }
< div style = { { marginTop : 6 , height : 4 , background : 'var(--bg-3)' , borderRadius : 99 , overflow : 'hidden' } } >
< div style = { {
width : ( r . status === 'done' ? 100 : r . progress ) + '%' ,
height : '100%' ,
background : r . status === 'done' ? 'var(--success)' : r . status === 'error' ? 'var(--danger)' : 'var(--accent)' ,
transition : 'width 200ms' ,
} } / >
< / div >
{ r . error && (
< div style = { { marginTop : 3 , fontSize : 11 , color : 'var(--danger)' } } > { r . error } < / div >
) }
< / div >
< span className = "mono" style = { { fontSize : 11.5 , minWidth : 88 , textAlign : 'right' , color : statusColor } } >
{ r . status === 'done' ? '✓ done'
: r . status === 'error' ? '✗ failed'
: r . status === 'processing' ? 'processing'
: r . status === 'downloading' ? 'downloading'
: 'queued' }
< / span >
< / div >
) ;
} ) }
< / div >
< / div >
) }
< / div >
< / div >
) ;
}
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
/ * = = = = = L i v e p r e v i e w ( H L S ) = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
Shared by RecorderRow + MonitorTile . The capture container writes
HLS segments to / live / { assetId } / index . m3u8 ( see capture - manager . js
and nginx . conf ) ; we attach hls . js to a < video > when a recorder is
actively recording and has a live asset .
=== === === === === === === === === === === === === === === === === === === === * /
2026-05-28 23:20:02 -04:00
function HlsPreview ( { assetId , recorderId , muted = true , controls = false , className } ) {
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
const videoRef = React . useRef ( null ) ;
const [ err , setErr ] = React . useState ( null ) ;
React . useEffect ( ( ) => {
if ( ! assetId || ! videoRef . current ) return ;
2026-05-28 23:20:02 -04:00
const url = recorderId
? '/api/v1/recorders/' + recorderId + '/live/' + assetId + '/index.m3u8'
: '/live/' + assetId + '/index.m3u8' ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
const v = videoRef . current ;
2026-05-24 16:52:04 -04:00
let destroyed = false ;
let retryTimer = 0 ;
let retryCount = 0 ;
const MAX _RETRIES = 8 ;
const clearRetry = ( ) => { if ( retryTimer ) { clearTimeout ( retryTimer ) ; retryTimer = 0 ; } } ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
// Safari can play HLS natively; everything else needs hls.js.
if ( v . canPlayType ( 'application/vnd.apple.mpegurl' ) ) {
2026-05-24 16:52:04 -04:00
const tryLoad = ( ) => {
if ( destroyed ) return ;
v . removeAttribute ( 'src' ) ;
v . load ( ) ;
v . src = url ;
v . play ( ) . catch ( ( ) => { } ) ;
} ;
const onErr = ( ) => {
if ( destroyed || retryCount >= MAX _RETRIES ) { setErr ( 'playback failed' ) ; return ; }
retryCount ++ ;
clearRetry ( ) ;
retryTimer = setTimeout ( tryLoad , Math . min ( 500 * Math . pow ( 2 , retryCount - 1 ) , 8000 ) ) ;
setErr ( 'connecting…' ) ;
} ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
v . addEventListener ( 'error' , onErr ) ;
2026-05-24 16:52:04 -04:00
v . addEventListener ( 'playing' , ( ) => { retryCount = 0 ; setErr ( null ) ; } , { once : false } ) ;
tryLoad ( ) ;
return ( ) => { destroyed = true ; clearRetry ( ) ; v . removeEventListener ( 'error' , onErr ) ; } ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
}
if ( ! window . Hls ) { setErr ( 'hls.js missing' ) ; return ; }
2026-05-24 16:52:04 -04:00
let hls = null ;
const startHls = ( ) => {
if ( destroyed ) return ;
hls = new window . Hls ( { liveSyncDurationCount : 2 , lowLatencyMode : true } ) ;
hls . loadSource ( url ) ;
hls . attachMedia ( v ) ;
hls . on ( window . Hls . Events . ERROR , ( _e , data ) => {
if ( data . fatal ) {
if ( retryCount >= MAX _RETRIES ) { setErr ( data . details || 'hls error' ) ; return ; }
retryCount ++ ;
clearRetry ( ) ;
try { hls . destroy ( ) ; } catch ( _ ) { }
hls = null ;
setErr ( 'connecting…' ) ;
retryTimer = setTimeout ( startHls , Math . min ( 500 * Math . pow ( 2 , retryCount - 1 ) , 8000 ) ) ;
}
} ) ;
hls . on ( window . Hls . Events . MANIFEST _PARSED , ( ) => { retryCount = 0 ; setErr ( null ) ; } ) ;
} ;
startHls ( ) ;
v . play ( ) . catch ( ( ) => { } ) ;
return ( ) => { destroyed = true ; clearRetry ( ) ; if ( hls ) { try { hls . destroy ( ) ; } catch ( _ ) { } } } ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
} , [ assetId ] ) ;
return (
< div className = { className } style = { { position : 'relative' , width : '100%' , height : '100%' , background : '#000' , overflow : 'hidden' } } >
< video
ref = { videoRef }
autoPlay
playsInline
muted = { muted }
controls = { controls }
style = { { width : '100%' , height : '100%' , objectFit : 'cover' , display : 'block' } }
/ >
{ err && (
< div style = { { position : 'absolute' , inset : 0 , display : 'grid' , placeItems : 'center' ,
color : 'var(--text-3)' , fontSize : 11 , background : 'rgba(0,0,0,0.5)' } } >
{ err }
< / div >
) }
< / div >
) ;
}
2026-06-02 17:40:52 -04:00
/* ===== Idle confidence monitor — 1 fps JPEG snapshot ===== */
/ * R e f r e s h e s a s i n g l e J P E G o n c e p e r s e c o n d . D o e s N O T o p e n t h e v i d e o F I F O a s a
* continuous reader , so it never competes with / slows down an active capture. */
function JpegSnapshotPreview ( { url } ) {
const [ src , setSrc ] = React . useState ( null ) ;
const [ failed , setFailed ] = React . useState ( false ) ;
React . useEffect ( ( ) => {
if ( ! url ) return ;
let destroyed = false ;
let timer = 0 ;
const tick = ( ) => {
if ( destroyed ) return ;
const bust = url + ( url . includes ( '?' ) ? '&' : '?' ) + 't=' + Date . now ( ) ;
const img = new Image ( ) ;
img . onload = ( ) => { if ( ! destroyed ) { setSrc ( bust ) ; setFailed ( false ) ; } } ;
img . onerror = ( ) => { if ( ! destroyed ) setFailed ( true ) ; } ;
img . src = bust ;
timer = setTimeout ( tick , 1000 ) ;
} ;
tick ( ) ;
return ( ) => { destroyed = true ; if ( timer ) clearTimeout ( timer ) ; } ;
} , [ url ] ) ;
return (
< div style = { { position : 'relative' , width : '100%' , height : '100%' , background : '#000' , overflow : 'hidden' } } >
{ src && (
< img src = { src } alt = "signal"
style = { { width : '100%' , height : '100%' , objectFit : 'cover' , display : 'block' } } / >
) }
{ ( ! src || failed ) && (
< div style = { { position : 'absolute' , inset : 0 , display : 'grid' , placeItems : 'center' ,
color : 'var(--text-3)' , fontSize : 11 , background : 'rgba(0,0,0,0.6)' } } >
{ failed ? 'no signal' : 'connecting…' }
< / div >
) }
< / div >
) ;
}
2026-06-02 07:23:39 -04:00
/* ===== Idle signal preview (always-on HLS from sidecar) ===== */
function HlsPreviewUrl ( { url } ) {
const videoRef = React . useRef ( null ) ;
const [ err , setErr ] = React . useState ( null ) ;
React . useEffect ( ( ) => {
if ( ! url || ! videoRef . current ) return ;
const v = videoRef . current ;
let destroyed = false ;
let hls = null ;
let retryTimer = 0 ;
let retryCount = 0 ;
const clearRetry = ( ) => { if ( retryTimer ) { clearTimeout ( retryTimer ) ; retryTimer = 0 ; } } ;
if ( v . canPlayType ( 'application/vnd.apple.mpegurl' ) ) {
const tryLoad = ( ) => {
if ( destroyed ) return ;
v . src = url ;
v . play ( ) . catch ( ( ) => { } ) ;
} ;
v . addEventListener ( 'error' , ( ) => {
retryCount ++ ;
clearRetry ( ) ;
retryTimer = setTimeout ( tryLoad , Math . min ( 2000 * retryCount , 15000 ) ) ;
setErr ( 'no signal' ) ;
} ) ;
v . addEventListener ( 'playing' , ( ) => { retryCount = 0 ; setErr ( null ) ; } , { once : false } ) ;
tryLoad ( ) ;
return ( ) => { destroyed = true ; clearRetry ( ) ; } ;
}
if ( ! window . Hls ) { setErr ( 'hls.js missing' ) ; return ; }
const startHls = ( ) => {
if ( destroyed ) return ;
hls = new window . Hls ( { liveSyncDurationCount : 2 , lowLatencyMode : true } ) ;
hls . loadSource ( url ) ;
hls . attachMedia ( v ) ;
hls . on ( window . Hls . Events . ERROR , ( _e , data ) => {
if ( data . fatal ) {
try { hls . destroy ( ) ; } catch ( _ ) { }
hls = null ;
retryCount ++ ;
setErr ( 'no signal' ) ;
clearRetry ( ) ;
retryTimer = setTimeout ( startHls , Math . min ( 2000 * retryCount , 15000 ) ) ;
}
} ) ;
hls . on ( window . Hls . Events . MANIFEST _PARSED , ( ) => { retryCount = 0 ; setErr ( null ) ; } ) ;
} ;
startHls ( ) ;
v . play ( ) . catch ( ( ) => { } ) ;
return ( ) => { destroyed = true ; clearRetry ( ) ; if ( hls ) { try { hls . destroy ( ) ; } catch ( _ ) { } } } ;
} , [ url ] ) ;
return (
< div style = { { position : 'relative' , width : '100%' , height : '100%' , background : '#000' , overflow : 'hidden' } } >
< video ref = { videoRef } autoPlay playsInline muted
style = { { width : '100%' , height : '100%' , objectFit : 'cover' , display : 'block' } } / >
{ err && (
< div style = { { position : 'absolute' , inset : 0 , display : 'grid' , placeItems : 'center' ,
color : 'var(--text-3)' , fontSize : 11 , background : 'rgba(0,0,0,0.6)' } } >
{ err }
< / div >
) }
< / div >
) ;
}
2026-05-22 10:07:13 -04:00
/* ===== Recorders ===== */
2026-05-22 10:55:19 -04:00
function _normRecorder ( r ) {
const cfg = r . source _config || { } ;
2026-06-02 00:09:17 -04:00
// Surface the capture port for SDI / Deltacast recorders so the recorder card
// can show which physical input the recorder is bound to. For Deltacast,
// cfg.port is the bridge port index (0-7). For Blackmagic SDI, cfg.device
// is something like /dev/blackmagic/dv0 — we slice off the trailing index.
let capturePort = null ;
if ( r . source _type === 'deltacast' ) {
capturePort = cfg . port != null ? ` Port ${ cfg . port } ` : null ;
} else if ( r . source _type === 'sdi' ) {
const dev = cfg . device || '' ;
const m = dev . match ( /(\d+)$/ ) ;
if ( m ) capturePort = ` SDI ${ m [ 1 ] } ` ;
}
2026-05-22 10:55:19 -04:00
return {
... r ,
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
source : r . source _type || '·' ,
url : cfg . url || cfg . address || cfg . srt _url || cfg . rtmp _url || r . source _type || '·' ,
codec : r . recording _codec || '·' ,
res : r . recording _resolution || '·' ,
2026-06-01 21:34:34 -04:00
framerate : r . recording _framerate || 'native' ,
2026-05-22 10:55:19 -04:00
node : r . node _id ? r . node _id . slice ( 0 , 8 ) : 'primary' ,
2026-06-02 00:09:17 -04:00
capturePort ,
2026-06-02 07:23:39 -04:00
previewUrl : r . preview _url || null ,
2026-06-01 21:34:34 -04:00
elapsed : '·' ,
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
bitrate : '·' ,
2026-05-22 10:55:19 -04:00
health : 100 ,
audio : false ,
} ;
}
2026-05-22 08:20:15 -04:00
function Recorders ( { navigate , onNew } ) {
2026-05-23 00:17:36 -04:00
const [ recorders , setRecorders ] = React . useState ( window . ZAMPP _DATA ? . RECORDERS || [ ] ) ;
2026-05-22 10:07:13 -04:00
2026-05-22 10:55:19 -04:00
const refresh = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/recorders' )
. then ( raw => {
const norm = ( raw || [ ] ) . map ( _normRecorder ) ;
window . ZAMPP _DATA . RECORDERS = norm ;
setRecorders ( norm ) ;
} )
2026-05-26 10:10:44 -04:00
. catch ( err => {
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// apiFetch already redirects on 401 - don't log noise, interval
2026-05-26 10:10:44 -04:00
// will be cleared automatically when the component unmounts on redirect (#55)
if ( err && err . message && err . message . includes ( 'Unauthenticated' ) ) return ;
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
window . DF _LOG . warn ( '[recorders] poll error:' , err ? . message ) ;
2026-05-26 10:10:44 -04:00
} ) ;
2026-05-22 10:55:19 -04:00
} , [ ] ) ;
2026-05-22 10:07:13 -04:00
React . useEffect ( ( ) => {
2026-05-22 11:35:13 -04:00
refresh ( ) ;
2026-05-22 10:55:19 -04:00
const id = setInterval ( refresh , 10000 ) ;
2026-05-23 14:52:04 -04:00
// Any screen that creates/starts/stops/deletes a recorder dispatches
// df:recorders-changed; refresh immediately instead of waiting for the tick.
const onChange = ( ) => refresh ( ) ;
window . addEventListener ( 'df:recorders-changed' , onChange ) ;
return ( ) => {
clearInterval ( id ) ;
window . removeEventListener ( 'df:recorders-changed' , onChange ) ;
} ;
} , [ refresh ] ) ;
2026-05-22 10:07:13 -04:00
const liveCount = recorders . filter ( r => r . status === 'recording' ) . length ;
const errCount = recorders . filter ( r => r . status === 'error' ) . length ;
2026-05-22 08:20:15 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Recorders < / h1 >
< span className = "subtitle" > Live ingest from SRT , RTMP , and SDI sources < / span >
< div className = "spacer" / >
2026-05-22 10:07:13 -04:00
{ ( liveCount > 0 || errCount > 0 ) && (
< div className = "status-pip" >
< span className = "dot" / >
< span > { liveCount > 0 ? liveCount + ' recording' : '' } { errCount > 0 ? ( liveCount > 0 ? ' · ' : '' ) + errCount + ' error' : '' } < / span >
< / div >
) }
2026-05-22 10:55:19 -04:00
< button className = "btn ghost sm" onClick = { refresh } > < Icon name = "refresh" / > Refresh < / button >
2026-05-22 08:20:15 -04:00
< button className = "btn primary" onClick = { onNew } > < Icon name = "plus" / > New recorder < / button >
< / div >
< div className = "page-body" >
2026-05-22 10:07:13 -04:00
{ recorders . length === 0 ? (
< div style = { { padding : 60 , textAlign : 'center' , color : 'var(--text-3)' } } >
No recorders configured .
< div style = { { marginTop : 12 } } > < button className = "btn primary" onClick = { onNew } > < Icon name = "plus" / > Add recorder < / button > < / div >
< / div >
) : (
< div className = "recorders-list" >
2026-05-22 10:55:19 -04:00
{ recorders . map ( r => < RecorderRow key = { r . id } recorder = { r } onRefresh = { refresh } / > ) }
2026-05-22 10:07:13 -04:00
< / div >
) }
2026-05-22 08:20:15 -04:00
< / div >
< / div >
) ;
}
2026-05-22 10:55:19 -04:00
function RecorderRow ( { recorder : initialRecorder , onRefresh } ) {
2026-05-28 18:25:56 -04:00
const PROJECTS = window . ZAMPP _DATA ? . PROJECTS || [ ] ;
2026-05-22 10:55:19 -04:00
const [ recorder , setRecorder ] = React . useState ( initialRecorder ) ;
const [ pending , setPending ] = React . useState ( false ) ;
const [ err , setErr ] = React . useState ( null ) ;
2026-05-22 11:35:13 -04:00
const [ liveStatus , setLiveStatus ] = React . useState ( null ) ;
2026-05-22 23:41:03 -04:00
const [ clipName , setClipName ] = React . useState ( '' ) ;
2026-05-28 18:25:56 -04:00
// Project override for this take. Defaults to the recorder's configured project.
const [ takeProjectId , setTakeProjectId ] = React . useState ( initialRecorder . project _id || PROJECTS [ 0 ] ? . id || '' ) ;
2026-05-31 18:31:07 -04:00
const [ confirm , confirmModal ] = window . useConfirm ( ) ;
2026-05-22 10:07:13 -04:00
const isRec = recorder . status === 'recording' ;
2026-05-28 18:25:56 -04:00
// Keep takeProjectId in sync if the recorder row changes (e.g. after a refresh).
React . useEffect ( ( ) => {
setTakeProjectId ( initialRecorder . project _id || PROJECTS [ 0 ] ? . id || '' ) ;
} , [ initialRecorder . id ] ) ;
2026-05-22 10:55:19 -04:00
React . useEffect ( ( ) => { setRecorder ( initialRecorder ) ; } , [ initialRecorder . id , initialRecorder . status ] ) ;
2026-05-22 11:35:13 -04:00
// Poll the status endpoint every 3s while recording for live feedback.
React . useEffect ( ( ) => {
if ( ! isRec ) { setLiveStatus ( null ) ; return ; }
const poll = ( ) => {
window . ZAMPP _API . fetch ( '/recorders/' + recorder . id + '/status' )
. then ( s => setLiveStatus ( s ) )
. catch ( ( ) => { } ) ;
} ;
poll ( ) ;
const id = setInterval ( poll , 3000 ) ;
return ( ) => clearInterval ( id ) ;
} , [ isRec , recorder . id ] ) ;
2026-06-01 21:34:34 -04:00
// Tick elapsed every second while recording. Seed from liveStatus.duration
// (authoritative from the capture container) when available; fall back to
// wall-clock diff from recorder.started_at so the counter never freezes.
const [ elapsedSecs , setElapsedSecs ] = React . useState ( 0 ) ;
React . useEffect ( ( ) => {
if ( ! isRec ) { setElapsedSecs ( 0 ) ; return ; }
const base = ( ) => {
if ( liveStatus && liveStatus . duration != null ) return liveStatus . duration ;
if ( recorder . started _at ) return Math . floor ( ( Date . now ( ) - new Date ( recorder . started _at ) . getTime ( ) ) / 1000 ) ;
return 0 ;
} ;
// Snap to latest authoritative value immediately, then tick from there.
const anchor = { at : Date . now ( ) , secs : base ( ) } ;
setElapsedSecs ( anchor . secs ) ;
const id = setInterval ( ( ) => {
setElapsedSecs ( anchor . secs + Math . floor ( ( Date . now ( ) - anchor . at ) / 1000 ) ) ;
} , 1000 ) ;
return ( ) => clearInterval ( id ) ;
// Re-anchor whenever liveStatus.duration arrives from the poll.
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ isRec , liveStatus && liveStatus . duration , recorder . started _at ] ) ;
2026-05-22 11:35:13 -04:00
const displayElapsed = React . useMemo ( ( ) => {
2026-06-01 21:34:34 -04:00
if ( ! isRec ) return '·' ;
const d = Math . max ( 0 , elapsedSecs ) ;
return String ( Math . floor ( d / 3600 ) ) . padStart ( 2 , '0' ) + ':' +
String ( Math . floor ( ( d % 3600 ) / 60 ) ) . padStart ( 2 , '0' ) + ':' +
String ( d % 60 ) . padStart ( 2 , '0' ) ;
} , [ isRec , elapsedSecs ] ) ;
// Show live fps when recording and signal is healthy; fall back to configured value.
const displayFramerate = React . useMemo ( ( ) => {
if ( isRec && liveStatus && liveStatus . currentFps != null && liveStatus . currentFps > 0 ) {
return Number ( liveStatus . currentFps ) . toFixed ( 2 ) + ' fps' ;
2026-05-22 11:35:13 -04:00
}
2026-06-01 21:34:34 -04:00
return recorder . framerate || 'native' ;
} , [ isRec , liveStatus , recorder . framerate ] ) ;
2026-05-22 11:35:13 -04:00
const displaySignal = liveStatus
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
? ( liveStatus . signal || '·' )
: ( isRec ? 'connecting…' : '·' ) ;
2026-05-22 11:35:13 -04:00
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
: displaySignal === 'stopped' ? 'var(--danger)'
: 'var(--text-3)' ;
2026-05-22 10:07:13 -04:00
const toggle = ( ) => {
2026-05-22 10:55:19 -04:00
if ( pending ) return ;
2026-05-22 10:07:13 -04:00
const action = isRec ? 'stop' : 'start' ;
2026-05-22 10:55:19 -04:00
setPending ( true ) ;
setErr ( null ) ;
setRecorder ( r => ( { ... r , status : isRec ? 'idle' : 'recording' } ) ) ;
2026-05-28 18:25:56 -04:00
// Ship the operator-typed clip name and project override on start; stop has no body.
const body = action === 'start'
? JSON . stringify ( {
... ( clipName . trim ( ) ? { clipName : clipName . trim ( ) } : { } ) ,
... ( takeProjectId ? { projectId : takeProjectId } : { } ) ,
} )
2026-05-22 23:41:03 -04:00
: undefined ;
window . ZAMPP _API . fetch ( '/recorders/' + recorder . id + '/' + action , { method : 'POST' , body } )
. then ( ( ) => {
setPending ( false ) ;
2026-05-28 18:25:56 -04:00
// Clear the clip name on a successful stop so the next take starts fresh.
// Leave takeProjectId as-is (operator likely wants the same project for the next take).
2026-05-22 23:41:03 -04:00
if ( action === 'stop' ) setClipName ( '' ) ;
onRefresh ( ) ;
2026-05-23 14:52:04 -04:00
window . dispatchEvent ( new CustomEvent ( 'df:recorders-changed' ) ) ;
// Stopping a recorder flips its asset from 'live' to 'ready' on the
// server side; tell the library/dashboard to re-pull.
if ( action === 'stop' ) {
window . dispatchEvent ( new CustomEvent ( 'df:assets-changed' ) ) ;
}
2026-05-22 23:41:03 -04:00
} )
2026-05-22 10:55:19 -04:00
. catch ( e => { setPending ( false ) ; setErr ( e . message || 'Failed' ) ; setRecorder ( initialRecorder ) ; } ) ;
2026-05-22 10:07:13 -04:00
} ;
2026-05-31 18:31:07 -04:00
const handleDelete = async ( ) => {
if ( ! ( await confirm ( { title : 'Delete recorder?' , message : 'Delete recorder "' + recorder . name + '"?\nThis will stop any active recording and cannot be undone.' } ) ) ) return ;
2026-05-22 12:24:10 -04:00
window . ZAMPP _API . fetch ( '/recorders/' + recorder . id , { method : 'DELETE' } )
2026-05-23 14:52:04 -04:00
. then ( ( ) => {
onRefresh ( ) ;
window . dispatchEvent ( new CustomEvent ( 'df:recorders-changed' ) ) ;
window . dispatchEvent ( new CustomEvent ( 'df:assets-changed' ) ) ;
} )
2026-05-22 12:24:10 -04:00
. catch ( e => setErr ( e . message || 'Delete failed' ) ) ;
} ;
2026-05-22 08:20:15 -04:00
return (
2026-05-22 10:07:13 -04:00
< div className = { 'recorder-row ' + recorder . status } >
2026-05-31 18:31:07 -04:00
{ confirmModal }
2026-05-22 08:20:15 -04:00
< div className = "recorder-preview" >
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
{ isRec && recorder . live _asset _id
2026-05-28 23:20:02 -04:00
? < HlsPreview assetId = { recorder . live _asset _id } recorderId = { recorder . id } / >
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
: isRec
? < LiveStrip seed = { recorder . id . length * 3 } count = { 6 } / >
: < div className = "recorder-empty" > < Icon name = { recorder . status === 'error' ? 'alert' : 'video' } size = { 20 } style = { { opacity : 0.4 } } / > < / div > }
2026-05-22 08:20:15 -04:00
< / div >
< div className = "recorder-info" >
2026-05-22 10:07:13 -04:00
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
2026-05-22 08:20:15 -04:00
< span style = { { fontWeight : 600 , fontSize : 13 } } > { recorder . name } < / span >
2026-05-22 10:07:13 -04:00
< span className = { 'badge ' + badgeForStatus ( recorder . status ) } >
2026-05-22 08:20:15 -04:00
< StatusDot status = { recorder . status } / > { recorder . status . toUpperCase ( ) }
< / span >
< span className = "badge outline" > { recorder . source } < / span >
2026-06-02 00:09:17 -04:00
{ recorder . capturePort && (
< span className = "badge outline" title = "Capture port" style = { { background : 'rgba(74,158,255,0.12)' , borderColor : 'rgba(74,158,255,0.4)' , color : 'var(--accent)' } } >
< Icon name = "signal" size = { 10 } style = { { marginRight : 4 , verticalAlign : - 1 } } / > { recorder . capturePort }
< / span >
) }
2026-05-22 08:20:15 -04:00
< / div >
< div className = "recorder-sub mono" > { recorder . url } < / div >
< div className = "recorder-sub" >
< span > { recorder . codec } < / span > < span > · < / span >
2026-05-22 10:07:13 -04:00
< span > { recorder . res } < / span >
2026-05-22 08:20:15 -04:00
< / div >
2026-05-22 10:55:19 -04:00
{ err && < div style = { { marginTop : 4 , fontSize : 11 , color : 'var(--danger)' } } > { err } < / div > }
2026-05-22 11:35:13 -04:00
{ liveStatus ? . lastError && isRec && (
< div style = { { marginTop : 4 , fontSize : 11 , color : 'var(--danger)' } } > { liveStatus . lastError } < / div >
) }
2026-05-22 08:20:15 -04:00
< / div >
< div className = "recorder-stats" >
< div className = "recorder-stat" >
< div className = "stat-label" > Elapsed < / div >
2026-05-22 11:35:13 -04:00
< div className = "stat-val mono" > { displayElapsed } < / div >
2026-05-22 08:20:15 -04:00
< / div >
< div className = "recorder-stat" >
2026-05-22 11:35:13 -04:00
< div className = "stat-label" > Signal < / div >
2026-05-23 13:19:48 -04:00
< div className = "stat-val signal-val" style = { { fontSize : 11 , color : signalColor } } >
< span className = { 'signal-dot ' + displaySignal } style = { { background : signalColor } } / >
{ displaySignal }
< / div >
2026-05-22 08:20:15 -04:00
< / div >
2026-06-01 21:34:34 -04:00
< div className = "recorder-stat" >
< div className = "stat-label" > Framerate < / div >
< div className = "stat-val mono" > { displayFramerate } < / div >
< / div >
2026-05-22 08:20:15 -04:00
< / div >
< div className = "recorder-actions" >
2026-05-22 23:41:03 -04:00
{ ! isRec && (
2026-05-28 18:25:56 -04:00
< >
{ PROJECTS . length > 0 && (
< select
className = "field-input"
value = { takeProjectId }
onChange = { e => setTakeProjectId ( e . target . value ) }
disabled = { pending }
style = { { width : 160 , padding : '5px 8px' , fontSize : 12 , appearance : 'auto' } }
title = "Project clips go to"
>
{ PROJECTS . map ( p => < option key = { p . id } value = { p . id } > { p . name } < / option > ) }
< / select >
) }
< input
className = "field-input"
value = { clipName }
onChange = { e => setClipName ( e . target . value ) }
placeholder = "Clip name (optional)"
disabled = { pending }
maxLength = { 80 }
onKeyDown = { e => { if ( e . key === 'Enter' ) toggle ( ) ; } }
style = { { width : 160 , padding : '5px 8px' , fontSize : 12 } }
title = "Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
/ >
< / >
2026-05-22 23:41:03 -04:00
) }
2026-05-22 10:07:13 -04:00
{ isRec
2026-05-22 10:55:19 -04:00
? < button className = "btn danger sm" onClick = { toggle } disabled = { pending } >
{ pending ? '…' : < > < span className = "rec-dot" / > Stop < / > }
< / button >
: < button className = "btn subtle sm" onClick = { toggle } disabled = { pending } >
{ pending ? '…' : < > < span className = "rec-dot" style = { { background : 'var(--live)' } } / > Record < / > }
< / button > }
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" onClick = { handleDelete } title = "Delete recorder" aria - label = "Delete recorder" style = { { color : 'var(--text-3)' } } >
2026-05-22 12:24:10 -04:00
< Icon name = "x" / >
< / button >
2026-05-22 08:20:15 -04:00
< / div >
< / div >
) ;
}
2026-05-22 10:07:13 -04:00
function badgeForStatus ( s ) {
return { recording : 'live' , armed : 'accent' , idle : 'neutral' , error : 'danger' , offline : 'neutral' , stopped : 'neutral' } [ s ] || 'neutral' ;
}
/* ===== Capture ===== */
2026-05-27 09:53:09 -04:00
function _captureSignalChip ( sig ) {
switch ( sig ) {
case 'receiving' : return { label : 'RECEIVING' , color : 'var(--success)' , pulse : true } ;
case 'connecting' : return { label : 'CONNECTING' , color : 'var(--accent)' , pulse : true } ;
case 'lost' : return { label : 'LOST' , color : 'var(--danger)' , pulse : false } ;
case 'error' : return { label : 'ERROR' , color : 'var(--danger)' , pulse : false } ;
case 'idle' : return { label : 'IDLE' , color : 'var(--text-3)' , pulse : false } ;
case 'no-recorder' : return { label : 'NO RECORDER' , color : 'var(--text-4)' , pulse : false } ;
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
default : return { label : sig || '·' , color : 'var(--text-4)' , pulse : false } ;
2026-05-27 09:53:09 -04:00
}
}
2026-05-22 10:55:19 -04:00
2026-05-27 09:53:09 -04:00
function CapturePortChip ( { port , sigEntry } ) {
const sig = sigEntry ? sigEntry . signal : null ;
const { label , color , pulse } = _captureSignalChip ( sig ) ;
const isReceiving = sig === 'receiving' ;
const portLabel = port . device ? port . device . split ( '/' ) . pop ( ) : ` port ${ port . index } ` ;
2026-05-22 10:07:13 -04:00
2026-05-27 09:53:09 -04:00
return (
< div
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
title = { sigEntry ? ` ${ sigEntry . recorder _name || 'recorder' } : ${ label } ` : label }
2026-05-27 09:53:09 -04:00
style = { {
display : 'flex' , alignItems : 'center' , gap : 6 ,
padding : '6px 12px' , borderRadius : 5 ,
background : isReceiving ? 'rgba(45,212,168,0.08)' : 'var(--bg-2)' ,
border : ` 1px solid ${ isReceiving ? 'rgba(45,212,168,0.35)' : 'var(--border)' } ` ,
minWidth : 120 ,
} }
>
< span style = { {
width : 8 , height : 8 , borderRadius : '50%' , flexShrink : 0 ,
background : sig ? color : 'var(--text-4)' ,
animation : pulse ? 'signalPulse 1.4s ease-in-out infinite' : 'none' ,
boxShadow : isReceiving ? ` 0 0 6px ${ color } ` : 'none' ,
} } / >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontSize : 11.5 , fontFamily : 'var(--font-mono)' , color : 'var(--text-2)' , fontWeight : 600 } } >
{ portLabel }
2026-05-22 10:07:13 -04:00
< / div >
2026-05-27 09:53:09 -04:00
< div style = { { display : 'flex' , alignItems : 'center' , gap : 6 , marginTop : 1 } } >
< span style = { { fontSize : 9.5 , fontWeight : 700 , letterSpacing : '0.05em' , color } } >
{ label }
< / span >
{ sigEntry && sigEntry . currentFps != null && (
< span style = { { fontSize : 9.5 , color : 'var(--text-4)' , fontFamily : 'var(--font-mono)' } } >
{ Number ( sigEntry . currentFps ) . toFixed ( 1 ) } fps
< / span >
) }
< / div >
< / div >
< / div >
) ;
}
function CaptureNodeCard ( { node , ports , portSignals } ) {
const svgRef = React . useRef ( null ) ;
const nodeSignalMap = React . useMemo ( ( ) => {
const map = new Map ( ) ;
ports . forEach ( p => {
const entry = portSignals [ ` ${ node . node _id } : ${ p . index } ` ] ;
if ( entry ) map . set ( p . index , entry . signal ) ;
} ) ;
return map ;
} , [ node . node _id , ports , portSignals ] ) ;
React . useEffect ( ( ) => {
if ( ! svgRef . current || ! window . BMDCards || ports . length === 0 ) return ;
svgRef . current . innerHTML = '' ;
const svg = window . BMDCards . render ( {
model : ports [ 0 ] . model || '' ,
deviceCount : ports . length ,
compact : true ,
portSignals : nodeSignalMap ,
} ) ;
if ( svg ) svgRef . current . appendChild ( svg ) ;
} , [ node . node _id , ports . length , nodeSignalMap ] ) ;
const receivingCount = ports . filter ( p => {
const e = portSignals [ ` ${ node . node _id } : ${ p . index } ` ] ;
return e && e . signal === 'receiving' ;
} ) . length ;
return (
< div className = "panel" style = { { padding : 0 , overflow : 'hidden' } } >
{ /* Node header */ }
< div style = { {
display : 'flex' , alignItems : 'center' , gap : 10 ,
padding : '12px 16px' ,
borderBottom : '1px solid var(--border)' ,
background : 'var(--bg-2)' ,
} } >
< StatusDot status = { node . online !== false ? 'online' : 'offline' } / >
< div style = { { flex : 1 } } >
< div style = { { fontWeight : 600 , fontSize : 13 } } >
{ ports [ 0 ] . model || 'DeckLink' }
< / div >
< div style = { { fontSize : 11 , color : 'var(--text-3)' , fontFamily : 'var(--font-mono)' } } >
{ node . hostname } { node . ip _address ? ` · ${ node . ip _address } ` : '' }
2026-05-22 10:07:13 -04:00
< / div >
< / div >
2026-05-27 09:53:09 -04:00
< div style = { { display : 'flex' , alignItems : 'center' , gap : 6 } } >
{ receivingCount > 0 && (
< span style = { {
fontSize : 10 , fontWeight : 700 , padding : '2px 7px' , borderRadius : 3 ,
background : 'rgba(45,212,168,0.15)' , color : 'var(--success)' ,
animation : 'signalPulse 1.4s ease-in-out infinite' ,
} } >
{ receivingCount } LIVE
< / span >
) }
< span style = { {
fontSize : 10 , fontWeight : 600 , padding : '2px 7px' , borderRadius : 3 ,
background : 'rgba(91,124,250,0.12)' , color : 'var(--accent)' ,
} } >
{ ports . length } PORT { ports . length !== 1 ? 'S' : '' }
< / span >
< / div >
2026-05-22 10:07:13 -04:00
< / div >
2026-05-27 09:53:09 -04:00
{ /* Port chips */ }
< div style = { { padding : '14px 16px' , display : 'flex' , flexWrap : 'wrap' , gap : 8 } } >
{ ports . map ( p => (
< CapturePortChip
key = { p . index }
port = { p }
sigEntry = { portSignals [ ` ${ node . node _id } : ${ p . index } ` ] || null }
/ >
) ) }
< / div >
{ /* BMD card SVG diagram */ }
{ window . BMDCards && (
< div ref = { svgRef } className = "bmd-card-diagram" style = { { padding : '0 16px 14px' } } / >
) }
< / div >
) ;
}
function Capture ( { navigate } ) {
const [ devices , setDevices ] = React . useState ( [ ] ) ;
const [ portSignals , setPortSignals ] = React . useState ( { } ) ;
const [ lastPoll , setLastPoll ] = React . useState ( null ) ;
// Group devices by node
const nodeGroups = React . useMemo ( ( ) => {
const map = new Map ( ) ;
devices . forEach ( d => {
const key = d . node _id || d . hostname || 'unknown' ;
if ( ! map . has ( key ) ) map . set ( key , { node _id : d . node _id , hostname : d . hostname , ip _address : d . ip _address , online : d . online , ports : [ ] } ) ;
map . get ( key ) . ports . push ( d ) ;
} ) ;
return Array . from ( map . values ( ) ) ;
} , [ devices ] ) ;
// Load device list once (changes rarely)
const loadDevices = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/cluster/devices/blackmagic' )
. then ( devs => setDevices ( Array . isArray ( devs ) ? devs : [ ] ) )
. catch ( ( ) => setDevices ( [ ] ) ) ;
} , [ ] ) ;
// Poll signal state every 3s
React . useEffect ( ( ) => {
const poll = ( ) => {
window . ZAMPP _API . fetch ( '/cluster/devices/blackmagic/signal' )
. then ( entries => {
const map = { } ;
( entries || [ ] ) . forEach ( e => { map [ ` ${ e . node _id } : ${ e . index } ` ] = e ; } ) ;
setPortSignals ( map ) ;
setLastPoll ( new Date ( ) ) ;
} )
. catch ( ( ) => { } ) ;
} ;
poll ( ) ;
const id = setInterval ( poll , 3000 ) ;
return ( ) => clearInterval ( id ) ;
} , [ ] ) ;
React . useEffect ( ( ) => { loadDevices ( ) ; } , [ ] ) ;
const totalPorts = devices . length ;
const receivingPorts = Object . values ( portSignals ) . filter ( e => e . signal === 'receiving' ) . length ;
2026-05-22 08:20:15 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Capture < / h1 >
2026-05-27 09:53:09 -04:00
< span className = "subtitle" > DeckLink SDI ingest < / span >
2026-05-22 08:20:15 -04:00
< div className = "spacer" / >
2026-05-27 09:53:09 -04:00
{ totalPorts > 0 && (
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 , marginRight : 8 } } >
{ receivingPorts > 0 && (
< span style = { {
fontSize : 11 , fontWeight : 700 , padding : '3px 8px' , borderRadius : 4 ,
background : 'rgba(45,212,168,0.15)' , color : 'var(--success)' ,
animation : 'signalPulse 1.4s ease-in-out infinite' ,
} } >
{ receivingPorts } / { totalPorts } LIVE
< / span >
) }
{ lastPoll && (
< span style = { { fontSize : 10.5 , color : 'var(--text-4)' , fontFamily : 'var(--font-mono)' } } >
updated { lastPoll . toLocaleTimeString ( ) }
< / span >
) }
< / div >
) }
2026-05-22 10:55:19 -04:00
< button className = "btn ghost sm" onClick = { loadDevices } > < Icon name = "refresh" / > Refresh < / button >
2026-05-22 08:20:15 -04:00
< / div >
< div className = "page-body" >
2026-05-27 09:53:09 -04:00
{ totalPorts === 0 ? (
< div style = { { padding : 60 , textAlign : 'center' , color : 'var(--text-3)' } } >
No DeckLink devices found in cluster .
2026-05-22 08:20:15 -04:00
< / div >
2026-05-27 09:53:09 -04:00
) : (
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 16 } } >
{ nodeGroups . map ( node => (
< CaptureNodeCard
key = { node . node _id || node . hostname }
node = { node }
ports = { node . ports }
portSignals = { portSignals }
/ >
) ) }
< / div >
) }
2026-05-22 08:20:15 -04:00
< / div >
< / div >
) ;
}
2026-05-22 10:07:13 -04:00
/* ===== Monitors ===== */
2026-05-22 08:20:15 -04:00
function Monitors ( { navigate } ) {
2026-05-23 00:17:36 -04:00
const [ recorders , setRecorders ] = React . useState ( window . ZAMPP _DATA ? . RECORDERS || [ ] ) ;
2026-05-31 17:56:45 -04:00
const [ channels , setChannels ] = React . useState ( [ ] ) ;
2026-05-22 08:20:15 -04:00
const [ grid , setGrid ] = React . useState ( 4 ) ;
2026-05-22 10:07:13 -04:00
2026-05-22 10:55:19 -04:00
React . useEffect ( ( ) => {
const refresh = ( ) => {
window . ZAMPP _API . fetch ( '/recorders' )
. then ( raw => {
const norm = ( raw || [ ] ) . map ( _normRecorder ) ;
window . ZAMPP _DATA . RECORDERS = norm ;
setRecorders ( norm ) ;
} )
. catch ( ( ) => { } ) ;
2026-05-31 17:56:45 -04:00
// Playout channels surface here too so an operator can watch on-air
// output alongside ingest. Degrade silently if the endpoint is absent.
window . ZAMPP _API . fetch ( '/playout/channels' )
. then ( raw => setChannels ( Array . isArray ( raw ) ? raw : [ ] ) )
. catch ( ( ) => setChannels ( [ ] ) ) ;
2026-05-22 10:55:19 -04:00
} ;
2026-05-22 11:35:13 -04:00
refresh ( ) ;
2026-06-02 07:15:21 -04:00
const id = setInterval ( refresh , 3000 ) ;
2026-05-22 10:55:19 -04:00
return ( ) => clearInterval ( id ) ;
} , [ ] ) ;
const videoFeeds = recorders . filter ( r => ! r . audio ) ;
const audioFeeds = recorders . filter ( r => r . audio ) . map ( r => ( { ... r , kind : 'audio' } ) ) ;
2026-05-22 11:10:00 -04:00
const allFeeds = [ ... videoFeeds . map ( r => ( { ... r , kind : 'video' } ) ) , ... audioFeeds ] ;
2026-05-22 10:07:13 -04:00
const feeds = allFeeds . slice ( 0 , grid * grid ) ;
2026-05-22 08:20:15 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Monitors < / h1 >
2026-05-22 10:07:13 -04:00
< span className = "subtitle" > Multi - cam live monitoring < / span >
2026-05-22 08:20:15 -04:00
< div className = "spacer" / >
< div className = "tab-group" >
{ [ 2 , 3 , 4 ] . map ( n => (
2026-05-22 10:07:13 -04:00
< button key = { n } className = { grid === n ? 'active' : '' } onClick = { ( ) => setGrid ( n ) } > { n } × { n } < / button >
2026-05-22 08:20:15 -04:00
) ) }
< / div >
< / div >
< div className = "page-body" >
2026-05-31 17:56:45 -04:00
{ feeds . length === 0 && channels . length === 0 ? (
< div style = { { padding : 60 , textAlign : 'center' , color : 'var(--text-3)' } } > No active feeds . Start a recorder or playout channel to see live video here . < / div >
2026-05-22 10:55:19 -04:00
) : (
2026-05-31 17:56:45 -04:00
< React.Fragment >
{ feeds . length > 0 && (
< React.Fragment >
< div className = "monitor-section-head" > Ingest < / div >
< div className = "monitors-grid" style = { { display : 'grid' , gridTemplateColumns : 'repeat(' + grid + ', 1fr)' , gap : 8 } } >
{ feeds . map ( ( f , i ) => < MonitorTile key = { f . id } feed = { f } seed = { i + 1 } / > ) }
< / div >
< / React.Fragment >
) }
{ channels . length > 0 && (
< React.Fragment >
< div className = "monitor-section-head" > Playout < / div >
< div className = "monitors-grid" style = { { display : 'grid' , gridTemplateColumns : 'repeat(' + grid + ', 1fr)' , gap : 8 } } >
{ channels . slice ( 0 , grid * grid ) . map ( c => < PlayoutMonitorTile key = { c . id } channel = { c } / > ) }
< / div >
< / React.Fragment >
) }
< / React.Fragment >
2026-05-22 10:55:19 -04:00
) }
2026-05-22 08:20:15 -04:00
< / div >
< / div >
) ;
}
2026-05-31 17:56:45 -04:00
function PlayoutMonitorTile ( { channel } ) {
const videoRef = React . useRef ( null ) ;
const hlsRef = React . useRef ( null ) ;
const onAir = channel . status === 'running' ;
const previewUrl = '/api/v1/playout/channels/' + channel . id + '/hls/index.m3u8' ;
React . useEffect ( ( ) => {
const vid = videoRef . current ;
if ( ! vid ) return ;
if ( hlsRef . current ) { try { hlsRef . current . destroy ( ) ; } catch ( _ ) { } hlsRef . current = null ; }
if ( ! onAir ) { vid . src = '' ; return ; }
if ( window . Hls && window . Hls . isSupported ( ) ) {
const hls = new window . Hls ( {
liveSyncDurationCount : 3 ,
liveMaxLatencyDurationCount : 6 ,
xhrSetup : ( xhr ) => { xhr . withCredentials = true ; } ,
} ) ;
hlsRef . current = hls ;
hls . loadSource ( previewUrl ) ;
hls . attachMedia ( vid ) ;
hls . on ( window . Hls . Events . MANIFEST _PARSED , ( ) => vid . play ( ) . catch ( ( ) => { } ) ) ;
} else if ( vid . canPlayType ( 'application/vnd.apple.mpegurl' ) ) {
vid . src = previewUrl ;
vid . play ( ) . catch ( ( ) => { } ) ;
}
return ( ) => {
if ( hlsRef . current ) { try { hlsRef . current . destroy ( ) ; } catch ( _ ) { } hlsRef . current = null ; }
} ;
} , [ onAir , channel . id ] ) ;
return (
< div className = "monitor-tile" >
{ onAir ? (
< video ref = { videoRef } muted playsInline autoPlay
style = { { width : '100%' , height : '100%' , objectFit : 'cover' , display : 'block' , background : '#000' } } / >
) : (
< FauxFrame / >
) }
{ onAir && < div style = { { position : 'absolute' , inset : 0 , border : '2px solid var(--live)' , pointerEvents : 'none' , borderRadius : 'inherit' } } / > }
{ ! onAir && (
< div style = { { position : 'absolute' , inset : 0 , display : 'grid' , placeItems : 'center' , color : 'var(--text-3)' , fontSize : 11 } } > channel idle < / div >
) }
< div style = { { position : 'absolute' , top : 8 , left : 8 , display : 'flex' , gap : 6 } } >
{ onAir ? < span className = "badge live" > ON AIR < / span > : < span className = "badge neutral" > IDLE < / span > }
< / div >
< div className = "monitor-tile-label" >
< span className = "name" > { channel . name } < / span >
{ channel . output _type && < span className = "time mono" > { String ( channel . output _type ) . toUpperCase ( ) } < / span > }
< / div >
< / div >
) ;
}
2026-05-22 08:20:15 -04:00
function MonitorTile ( { feed , seed } ) {
2026-05-22 10:55:19 -04:00
const [ levels , setLevels ] = React . useState ( [ 0.65 , 0.78 ] ) ;
const isLive = feed . status === 'recording' ;
2026-06-02 07:15:21 -04:00
// Persist the last known asset ID so we can show frozen HLS/thumbnail after
// a recording stops. Cleared only when a new recording starts on this recorder.
const [ lastAssetId , setLastAssetId ] = React . useState ( feed . live _asset _id || null ) ;
const [ lastRecorderId , setLastRecorderId ] = React . useState ( feed . id ) ;
const [ frozenThumb , setFrozenThumb ] = React . useState ( null ) ;
React . useEffect ( ( ) => {
if ( feed . live _asset _id ) {
setLastAssetId ( feed . live _asset _id ) ;
setLastRecorderId ( feed . id ) ;
setFrozenThumb ( null ) ; // reset frozen thumb — live HLS takes over
}
} , [ feed . live _asset _id ] ) ;
// When recording stops, try to fetch a thumbnail for the last asset so we can
// show a frozen frame instead of blank noise.
React . useEffect ( ( ) => {
if ( isLive || ! lastAssetId ) return ;
if ( frozenThumb ) return ; // already fetched
let cancelled = false ;
window . ZAMPP _API . fetch ( '/assets/' + lastAssetId + '/thumbnail' )
. then ( data => {
if ( ! cancelled && data && data . url ) setFrozenThumb ( data . url ) ;
} )
. catch ( ( ) => { } ) ;
return ( ) => { cancelled = true ; } ;
} , [ isLive , lastAssetId ] ) ;
// Tick elapsed while recording. Seed from feed.started_at (populated by Docker
// inspect on the mam-api side). Falls back to 0 if not yet available.
const [ elapsedSecs , setElapsedSecs ] = React . useState ( 0 ) ;
React . useEffect ( ( ) => {
if ( ! isLive ) { setElapsedSecs ( 0 ) ; return ; }
const tick = ( ) => {
if ( feed . started _at ) {
setElapsedSecs ( Math . max ( 0 , Math . floor ( ( Date . now ( ) - new Date ( feed . started _at ) . getTime ( ) ) / 1000 ) ) ) ;
}
} ;
tick ( ) ;
const id = setInterval ( tick , 1000 ) ;
return ( ) => clearInterval ( id ) ;
} , [ isLive , feed . started _at ] ) ;
2026-05-22 10:55:19 -04:00
React . useEffect ( ( ) => {
if ( ! isLive ) return ;
const id = setInterval ( ( ) => {
setLevels ( [ 0.3 + Math . random ( ) * 0.55 , 0.3 + Math . random ( ) * 0.55 ] ) ;
} , 180 ) ;
return ( ) => clearInterval ( id ) ;
} , [ isLive ] ) ;
2026-06-02 07:15:21 -04:00
const displayElapsed = _fmtElapsed ( elapsedSecs * 1000 ) ;
2026-05-22 10:07:13 -04:00
if ( feed . kind === 'audio' ) {
2026-05-22 08:20:15 -04:00
return (
< div className = "monitor-tile audio" >
2026-05-22 10:07:13 -04:00
< div style = { { flex : 1 , display : 'grid' , placeItems : 'center' , padding : 24 } } >
2026-05-22 08:20:15 -04:00
< Waveform seed = { seed * 7 } color = "var(--accent)" / >
< / div >
2026-05-22 10:07:13 -04:00
< div style = { { display : 'flex' , gap : 4 , padding : '0 16px 16px' , justifyContent : 'center' } } >
2026-05-22 10:55:19 -04:00
< AudioMeter level = { levels [ 0 ] } vertical / >
< AudioMeter level = { levels [ 1 ] } vertical / >
2026-05-22 08:20:15 -04:00
< / div >
< div className = "monitor-tile-label" >
< span className = "badge live" > LIVE < / span >
< span className = "name" > { feed . name } < / span >
< / div >
< / div >
) ;
}
2026-05-22 10:55:19 -04:00
2026-06-02 07:15:21 -04:00
// Content priority: live HLS > frozen HLS briefly after stop > static thumbnail > noise
let tileContent ;
if ( isLive && feed . live _asset _id ) {
tileContent = < HlsPreview assetId = { feed . live _asset _id } recorderId = { feed . id } / > ;
} else if ( ! isLive && lastAssetId ) {
// After stopping: HlsPreview will naturally degrade as segments expire and
// show its own "connecting…" overlay. Once frozenThumb is loaded it replaces it.
if ( frozenThumb ) {
tileContent = (
< div style = { { position : 'relative' , width : '100%' , height : '100%' , background : '#000' , overflow : 'hidden' } } >
< img src = { frozenThumb } alt = ""
style = { { width : '100%' , height : '100%' , objectFit : 'cover' , display : 'block' , opacity : 0.65 , filter : 'grayscale(0.3)' } } / >
< div style = { { position : 'absolute' , inset : 0 , background : 'rgba(0,0,0,0.25)' } } / >
< / div >
) ;
} else {
// HLS segments may still be hot right after stopping — keep player alive briefly
tileContent = < HlsPreview assetId = { lastAssetId } recorderId = { lastRecorderId } / > ;
}
2026-06-02 07:23:39 -04:00
} else if ( ! isLive && feed . previewUrl ) {
2026-06-02 17:40:52 -04:00
tileContent = < JpegSnapshotPreview url = { feed . previewUrl } / > ;
2026-06-02 07:15:21 -04:00
} else {
tileContent = < FauxFrame / > ;
}
2026-05-22 08:20:15 -04:00
return (
< div className = "monitor-tile" >
2026-06-02 07:15:21 -04:00
{ tileContent }
2026-05-22 11:10:00 -04:00
{ isLive && < div style = { { position : 'absolute' , inset : 0 , border : '2px solid var(--live)' , pointerEvents : 'none' , borderRadius : 'inherit' } } / > }
2026-06-02 07:15:21 -04:00
< div style = { { position : 'absolute' , top : 8 , left : 8 , display : 'flex' , gap : 6 , flexWrap : 'wrap' , maxWidth : '70%' } } >
2026-05-22 11:10:00 -04:00
{ isLive && < span className = "badge live" > REC < / span > }
{ feed . status === 'stopped' && < span className = "badge neutral" > IDLE < / span > }
{ feed . status === 'idle' && < span className = "badge neutral" > IDLE < / span > }
{ feed . status === 'error' && < span className = "badge danger" > ERR < / span > }
2026-06-02 07:15:21 -04:00
{ feed . capturePort && (
< span className = "badge outline" style = { { fontSize : 9 , padding : '1px 5px' , opacity : 0.8 } } >
{ feed . capturePort }
< / span >
) }
2026-05-22 08:20:15 -04:00
< / div >
2026-05-22 10:55:19 -04:00
{ isLive && (
< div style = { { position : 'absolute' , top : 8 , right : 8 , display : 'flex' , gap : 4 } } >
< AudioMeter level = { levels [ 0 ] } vertical / >
< AudioMeter level = { levels [ 1 ] } vertical / >
< / div >
) }
2026-05-22 08:20:15 -04:00
< div className = "monitor-tile-label" >
< span className = "name" > { feed . name } < / span >
2026-06-02 07:15:21 -04:00
{ isLive && < span className = "time mono" > { displayElapsed } < / span > }
2026-05-22 08:20:15 -04:00
< / div >
< / div >
) ;
}
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
/* ===== Schedule ===== */
const _STATUS _BADGE = {
pending : { cls : 'neutral' , label : 'pending' } ,
running : { cls : 'success' , label : 'recording' } ,
completed : { cls : 'accent' , label : 'completed' } ,
cancelled : { cls : 'warning' , label : 'cancelled' } ,
failed : { cls : 'danger' , label : 'failed' } ,
} ;
function _fmtWhen ( iso ) {
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
if ( ! iso ) return '·' ;
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
const d = new Date ( iso ) ;
// Local-time, short, human; e.g. "May 22 · 7:30 PM"
return d . toLocaleString ( undefined , {
month : 'short' , day : 'numeric' ,
hour : 'numeric' , minute : '2-digit' ,
} ) ;
}
function _durationMin ( startISO , endISO ) {
return Math . round ( ( new Date ( endISO ) - new Date ( startISO ) ) / 60000 ) ;
}
2026-05-23 15:48:09 -04:00
// ── EPG (timeline) helpers ───────────────────────────────────────────────────
//
// The Schedule screen is a broadcast-control-room timeline: recorders are
// rows, time is the horizontal axis. Helpers below convert between Date and
// "minutes into local day" so we can position absolute-positioned event
// blocks against a fixed --epg-pph (pixels-per-hour) CSS variable.
//
function _dayStart ( d ) {
const x = new Date ( d ) ;
x . setHours ( 0 , 0 , 0 , 0 ) ;
return x ;
2026-05-23 14:52:04 -04:00
}
2026-05-23 15:48:09 -04:00
function _dayEnd ( d ) {
const x = _dayStart ( d ) ;
x . setDate ( x . getDate ( ) + 1 ) ;
return x ;
}
function _addDays ( d , n ) {
const x = new Date ( d ) ;
x . setDate ( x . getDate ( ) + n ) ;
return x ;
}
function _minutesIntoDay ( date , dayStart ) {
return Math . max ( 0 , Math . min ( 24 * 60 , ( date - dayStart ) / 60000 ) ) ;
}
function _eventOverlapsDay ( ev , dayStart , dayEnd ) {
const s = new Date ( ev . start _at ) ;
const e = new Date ( ev . end _at ) ;
return s < dayEnd && e > dayStart ;
2026-05-23 14:52:04 -04:00
}
function _sameDay ( a , b ) {
return a . getFullYear ( ) === b . getFullYear ( )
&& a . getMonth ( ) === b . getMonth ( )
&& a . getDate ( ) === b . getDate ( ) ;
}
2026-05-23 15:48:09 -04:00
function _fmtDay ( d ) {
return d . toLocaleDateString ( undefined , { weekday : 'short' , month : 'short' , day : 'numeric' } ) ;
}
2026-05-23 14:52:04 -04:00
function _fmtTime ( d ) {
return d . toLocaleTimeString ( undefined , { hour : 'numeric' , minute : '2-digit' } ) ;
}
2026-05-23 15:48:09 -04:00
function _fmtHour ( h ) {
// 0 → "12 AM", 12 → "12 PM", 18 → "6 PM"
const ampm = h < 12 ? 'AM' : 'PM' ;
const hr = ( ( h + 11 ) % 12 ) + 1 ;
return hr + ' ' + ampm ;
}
function _fmtCountdown ( ms ) {
if ( ms <= 0 ) return 'now' ;
const s = Math . floor ( ms / 1000 ) ;
if ( s < 60 ) return 'in ' + s + 's' ;
const m = Math . floor ( s / 60 ) ;
if ( m < 60 ) return 'in ' + m + 'm' ;
const h = Math . floor ( m / 60 ) ;
return 'in ' + h + 'h ' + ( m % 60 ) + 'm' ;
}
function _fmtElapsed ( ms ) {
if ( ms < 0 ) ms = 0 ;
const s = Math . floor ( ms / 1000 ) ;
return String ( Math . floor ( s / 3600 ) ) . padStart ( 2 , '0' ) + ':' +
String ( Math . floor ( ( s % 3600 ) / 60 ) ) . padStart ( 2 , '0' ) + ':' +
String ( s % 60 ) . padStart ( 2 , '0' ) ;
}
2026-05-23 14:52:04 -04:00
2026-05-23 15:48:09 -04:00
// Pick a stable color for a project_id given the global PROJECTS list.
function _projectColor ( projectId , projects ) {
if ( ! projectId ) return null ;
const p = ( projects || [ ] ) . find ( p => p . id === projectId ) ;
return p ? . color || null ;
}
2026-05-23 14:52:04 -04:00
2026-05-23 15:48:09 -04:00
// ── EPG components ───────────────────────────────────────────────────────────
function _StatusStrip ( { schedules , recorders , now , projects } ) {
// What's recording: any schedule whose window contains `now` AND whose
// status is 'running'. (Manually-started recorders without a schedule
// surface on the Recorders screen; the schedule strip stays focused on
// planned events so the operator can trust it.)
const active = ( schedules || [ ] ) . filter ( s => {
const start = new Date ( s . start _at ) ;
const end = new Date ( s . end _at ) ;
return start <= now && now < end && ( s . status === 'running' || s . status === 'pending' ) ;
} ) ;
// Next up: earliest pending schedule strictly in the future.
const upcoming = ( schedules || [ ] )
. filter ( s => s . status === 'pending' && new Date ( s . start _at ) > now )
. sort ( ( a , b ) => new Date ( a . start _at ) - new Date ( b . start _at ) ) ;
const next = upcoming [ 0 ] ;
const recMap = { } ;
( recorders || [ ] ) . forEach ( r => { recMap [ r . id ] = r ; } ) ;
2026-05-23 14:52:04 -04:00
return (
2026-05-23 15:48:09 -04:00
< div className = "epg-status" >
< div className = "epg-status-row" >
{ active . length === 0 ? (
< >
< span className = "epg-status-dot idle" / >
< span className = "epg-status-label" > Nothing scheduled right now < / span >
< / >
) : (
< >
< span className = "epg-status-dot live" / >
< span className = "epg-status-label" > On air < / span >
< div className = "epg-status-active" >
{ active . map ( s => {
const rec = recMap [ s . recorder _id ] ;
const elapsed = _fmtElapsed ( now - new Date ( s . start _at ) ) ;
const endsAt = _fmtTime ( new Date ( s . end _at ) ) ;
const color = _projectColor ( rec ? . project _id , projects ) ;
return (
< span key = { s . id } className = "epg-status-pill" >
{ color && < span className = "epg-status-pill-bar" style = { { background : color } } / > }
< span className = "epg-status-pill-name" > { s . name } < / span >
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
< span className = "epg-status-pill-rec mono" > { rec ? . name || ( s . recorder _id ? s . recorder _id . slice ( 0 , 8 ) : 'unassigned' ) } < / span >
2026-05-23 15:48:09 -04:00
< span className = "epg-status-pill-time mono" > { elapsed } · ends { endsAt } < / span >
< / span >
) ;
} ) }
2026-05-23 14:52:04 -04:00
< / div >
2026-05-23 15:48:09 -04:00
< / >
) }
< / div >
< div className = "epg-status-row sub" >
{ next ? (
< >
< span className = "epg-status-label muted" > Next up < / span >
< span className = "epg-status-next" > { next . name } < / span >
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
< span className = "epg-status-next-rec mono" > { recMap [ next . recorder _id ] ? . name || ( next . recorder _id ? next . recorder _id . slice ( 0 , 8 ) : 'unassigned' ) } < / span >
2026-05-23 15:48:09 -04:00
< span className = "epg-status-next-time mono" > { _fmtCountdown ( new Date ( next . start _at ) - now ) } · { _fmtTime ( new Date ( next . start _at ) ) } < / span >
< / >
) : (
< span className = "epg-status-label muted" > No upcoming schedules < / span >
) }
2026-05-23 14:52:04 -04:00
< / div >
< / div >
) ;
}
2026-05-23 15:48:09 -04:00
function _EpgRuler ( { pph } ) {
// 25 ticks so the last one (24:00) labels the right edge. The 24h column
// ends at 24*pph; the 25th tick is purely a label and has zero width.
const hours = [ ] ;
for ( let h = 0 ; h <= 24 ; h ++ ) hours . push ( h ) ;
return (
< div className = "epg-ruler" style = { { width : 24 * pph } } >
{ hours . map ( h => (
< div key = { h } className = { 'epg-ruler-tick ' + ( h === 24 ? 'end' : '' ) }
style = { { left : h * pph } } >
< span > { h === 24 ? '' : _fmtHour ( h ) } < / span >
< / div >
) ) }
< / div >
) ;
}
2026-05-23 16:33:57 -04:00
// Minimum schedule length the UI permits while dragging. Anything shorter
// rarely reflects a real plan and would let the operator accidentally
// dismiss a block to zero width.
const _EPG _MIN _MS = 5 * 60 * 1000 ;
// Drag snap quantum. Mirrors the new-schedule click snap so a resized
// block lines up to the same grid an operator just placed a new one on.
const _EPG _SNAP _MIN = 15 ;
// Pointer travel (in px) before we treat the gesture as a drag rather
// than a click. Below this, pointerup fires the click handler.
const _EPG _DRAG _THRESHOLD = 4 ;
function _EventBlock ( { event , recorder , dayStart , dayEnd , pph , now , projects , onClick , onContextMenu , onResize } ) {
// Drag state: null when idle, otherwise the in-flight resize/move
// describing the original times and the current (snapped) preview times.
// We render from this preview while dragging so the block follows the
// cursor without round-tripping through props.
const [ drag , setDrag ] = React . useState ( null ) ;
const blockRef = React . useRef ( null ) ;
const eventStartMs = new Date ( event . start _at ) . getTime ( ) ;
const eventEndMs = new Date ( event . end _at ) . getTime ( ) ;
const isLive = event . status === 'running' || ( event . status === 'pending' && eventStartMs <= now . getTime ( ) && now . getTime ( ) < eventEndMs ) ;
2026-05-23 15:48:09 -04:00
const isFailed = event . status === 'failed' ;
2026-05-23 16:33:57 -04:00
const isPast = ( event . status === 'completed' || event . status === 'cancelled' ) || eventEndMs < now . getTime ( ) ;
2026-05-23 15:48:09 -04:00
const color = _projectColor ( recorder ? . project _id , projects ) ;
2026-05-23 16:33:57 -04:00
// Only pending schedules can be resized. The API rejects PUTs against
// running schedules outright; cancelling them is what an operator
// actually wants there. Terminal statuses are read-only.
const canDrag = event . status === 'pending' ;
const startDrag = ( e , type ) => {
if ( ! canDrag ) return ;
if ( e . button !== 0 ) return ; // ignore right-click
e . stopPropagation ( ) ;
try { blockRef . current . setPointerCapture ( e . pointerId ) ; } catch ( _ ) { }
setDrag ( {
type , pointerId : e . pointerId ,
startX : e . clientX ,
origStart : eventStartMs , origEnd : eventEndMs ,
currStart : eventStartMs , currEnd : eventEndMs ,
moved : false ,
} ) ;
} ;
const onPointerMove = ( ev ) => {
if ( ! drag ) return ;
const dx = ev . clientX - drag . startX ;
if ( ! drag . moved && Math . abs ( dx ) < _EPG _DRAG _THRESHOLD ) return ;
const snapMs = _EPG _SNAP _MIN * 60 * 1000 ;
const dMs = Math . round ( ( dx / pph ) * 3600 * 1000 / snapMs ) * snapMs ;
const dayStartMs = dayStart . getTime ( ) ;
const dayEndMs = dayEnd . getTime ( ) ;
let cs = drag . origStart , ce = drag . origEnd ;
if ( drag . type === 'left' ) {
cs = Math . max ( dayStartMs , Math . min ( drag . origEnd - _EPG _MIN _MS , drag . origStart + dMs ) ) ;
} else if ( drag . type === 'right' ) {
ce = Math . min ( dayEndMs , Math . max ( drag . origStart + _EPG _MIN _MS , drag . origEnd + dMs ) ) ;
} else if ( drag . type === 'body' ) {
cs = drag . origStart + dMs ;
ce = drag . origEnd + dMs ;
// Clamp to the day; preserve duration when bumping against an edge.
if ( cs < dayStartMs ) { ce += ( dayStartMs - cs ) ; cs = dayStartMs ; }
if ( ce > dayEndMs ) { cs -= ( ce - dayEndMs ) ; ce = dayEndMs ; }
}
setDrag ( { ... drag , currStart : cs , currEnd : ce , moved : true } ) ;
} ;
const endDrag = ( ev ) => {
if ( ! drag ) return ;
try { blockRef . current . releasePointerCapture ( drag . pointerId ) ; } catch ( _ ) { }
const d = drag ;
setDrag ( null ) ;
if ( ! d . moved ) {
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// Treat as a click - open the edit modal.
2026-05-23 16:33:57 -04:00
onClick ( event ) ;
return ;
}
if ( d . currStart === d . origStart && d . currEnd === d . origEnd ) return ;
onResize ( event , new Date ( d . currStart ) . toISOString ( ) , new Date ( d . currEnd ) . toISOString ( ) ) ;
} ;
// Render from drag preview while a gesture is in flight so the block
// tracks the pointer; otherwise from the canonical event prop.
const dispStartMs = drag ? drag . currStart : eventStartMs ;
const dispEndMs = drag ? drag . currEnd : eventEndMs ;
const dispStart = new Date ( dispStartMs ) ;
const dispEnd = new Date ( dispEndMs ) ;
const startMin = _minutesIntoDay ( dispStart , dayStart ) ;
const endMin = _minutesIntoDay ( dispEnd , dayStart ) ;
const left = ( startMin / 60 ) * pph ;
const width = Math . max ( 40 , ( ( endMin - startMin ) / 60 ) * pph ) ;
2026-06-02 00:09:17 -04:00
// Schedules backed by a recorder configured to write a growing file get a
// green accent so operators can tell at a glance which slots will produce
// an edit-while-recording deliverable (vs. close-then-publish).
const isGrowing = ! ! ( recorder && recorder . growing _enabled ) ;
2026-05-23 15:48:09 -04:00
const classes = [ 'epg-block' ] ;
2026-05-23 16:33:57 -04:00
if ( isLive ) classes . push ( 'live' ) ;
if ( isFailed ) classes . push ( 'failed' ) ;
2026-05-23 15:48:09 -04:00
else if ( isPast ) classes . push ( 'past' ) ;
2026-05-23 16:33:57 -04:00
if ( drag && drag . moved ) classes . push ( 'dragging' ) ;
if ( canDrag ) classes . push ( 'resizable' ) ;
2026-06-02 00:09:17 -04:00
if ( isGrowing ) classes . push ( 'growing' ) ;
const blockColor = isGrowing ? '#2ecc71' : ( color || 'var(--text-3)' ) ;
2026-05-23 15:48:09 -04:00
return (
2026-05-23 16:33:57 -04:00
< div
ref = { blockRef }
2026-05-23 15:48:09 -04:00
className = { classes . join ( ' ' ) }
2026-06-02 00:09:17 -04:00
style = { { left , width , '--epg-block-color' : blockColor } }
2026-05-23 16:33:57 -04:00
onContextMenu = { ( ev ) => { ev . preventDefault ( ) ; ev . stopPropagation ( ) ; onContextMenu ( event , ev ) ; } }
onPointerMove = { onPointerMove }
onPointerUp = { endDrag }
onPointerCancel = { endDrag }
2026-06-02 00:09:17 -04:00
title = { event . name + ( isGrowing ? ' · GROWING' : '' ) + ' · ' + _fmtTime ( dispStart ) + ' → ' + _fmtTime ( dispEnd ) + ( event . error _message ? ' · ' + event . error _message : '' ) } >
2026-05-23 15:48:09 -04:00
< span className = "epg-block-bar" / >
2026-05-23 16:33:57 -04:00
{ / * B o d y c l i c k → e d i t , b o d y d r a g → m o v e . W e h a n g t h e c l i c k o n p o i n t e r u p
so the threshold check above can demote a drag back to a click . * / }
< div className = "epg-block-body" onPointerDown = { ( ev ) => startDrag ( ev , 'body' ) } >
< span className = "epg-block-name" > { event . name } < / span >
< span className = "epg-block-time mono" > { _fmtTime ( dispStart ) } { drag && drag . moved ? ' → ' + _fmtTime ( dispEnd ) : '' } < / span >
2026-06-02 00:09:17 -04:00
{ isGrowing && < span className = "epg-block-glyph growing" title = "growing file (edit-while-record)" style = { { color : '#2ecc71' } } > ▶ < / span > }
2026-05-23 16:33:57 -04:00
{ isLive && < span className = "epg-block-glyph live" title = "on air" > ● < / span > }
{ isFailed && < span className = "epg-block-glyph failed" title = { event . error _message || 'failed' } > ! < / span > }
< / div >
{ canDrag && (
< >
< span className = "epg-block-handle left"
onPointerDown = { ( ev ) => startDrag ( ev , 'left' ) }
title = "Drag to change start time" / >
< span className = "epg-block-handle right"
onPointerDown = { ( ev ) => startDrag ( ev , 'right' ) }
title = "Drag to change end time" / >
< / >
) }
< / div >
2026-05-23 15:48:09 -04:00
) ;
}
2026-05-23 16:33:57 -04:00
function _EpgRow ( { recorder , schedules , dayStart , dayEnd , pph , now , projects , onEventClick , onEmptyClick , onEventContextMenu , onEventResize } ) {
2026-05-23 15:48:09 -04:00
const dayEvents = ( schedules || [ ] ) . filter ( s => s . recorder _id === recorder . id && _eventOverlapsDay ( s , dayStart , dayEnd ) ) ;
2026-05-23 16:33:57 -04:00
const handleRowPointerUp = ( e ) => {
// Open the new-schedule modal only on a real click in the empty
// gutter. Clicks on event blocks stopPropagation themselves; we also
// guard against the tail of a block-drag bubbling up here.
if ( e . target !== e . currentTarget ) return ;
if ( e . button !== 0 ) return ;
2026-05-23 15:48:09 -04:00
const rect = e . currentTarget . getBoundingClientRect ( ) ;
const x = e . clientX - rect . left ;
2026-05-23 16:33:57 -04:00
const minutes = Math . max ( 0 , Math . min ( 24 * 60 - 30 , Math . round ( ( x / pph ) * 60 / _EPG _SNAP _MIN ) * _EPG _SNAP _MIN ) ) ;
2026-05-23 15:48:09 -04:00
const start = new Date ( dayStart ) ;
start . setMinutes ( minutes ) ;
onEmptyClick ( recorder , start ) ;
} ;
return (
2026-05-23 16:33:57 -04:00
< div className = "epg-row" style = { { width : 24 * pph } } onPointerUp = { handleRowPointerUp } >
2026-05-23 15:48:09 -04:00
{ dayEvents . map ( s => (
< _EventBlock
key = { s . id }
event = { s }
recorder = { recorder }
dayStart = { dayStart }
dayEnd = { dayEnd }
pph = { pph }
now = { now }
projects = { projects }
2026-05-23 16:33:57 -04:00
onClick = { onEventClick }
onContextMenu = { onEventContextMenu }
onResize = { onEventResize } / >
2026-05-23 15:48:09 -04:00
) ) }
< / div >
) ;
}
2026-05-23 16:33:57 -04:00
// ── Right-click menu for an EPG event block ─────────────────────────────────
// Same pattern as AssetContextMenu (screens-library.jsx): viewport-clamped,
// dismissed on outside click. Per-status action filtering mirrors the
// buttons rendered in the List view so the two surfaces stay consistent.
function _ScheduleContextMenu ( { schedule , x , y , onClose , onEdit , onCancel , onDelete , onCopyId } ) {
const ref = React . useRef ( null ) ;
const [ pos , setPos ] = React . useState ( { left : x , top : y } ) ;
React . useLayoutEffect ( ( ) => {
if ( ! ref . current ) return ;
const r = ref . current . getBoundingClientRect ( ) ;
const margin = 8 ;
let nx = x , ny = y ;
if ( x + r . width + margin > window . innerWidth ) nx = window . innerWidth - r . width - margin ;
if ( y + r . height + margin > window . innerHeight ) ny = window . innerHeight - r . height - margin ;
setPos ( { left : Math . max ( margin , nx ) , top : Math . max ( margin , ny ) } ) ;
} , [ x , y ] ) ;
const canEdit = schedule . status === 'pending' || schedule . status === 'failed' ;
const canCancel = schedule . status === 'pending' || schedule . status === 'running' ;
const canDelete = schedule . status !== 'running' ;
return (
< div ref = { ref } className = "ctx-menu" style = { { left : pos . left , top : pos . top } }
onClick = { ( e ) => e . stopPropagation ( ) }
onContextMenu = { ( e ) => { e . preventDefault ( ) ; e . stopPropagation ( ) ; } } >
< div className = "ctx-header" > { schedule . name } < / div >
{ canEdit && < button onClick = { onEdit } > < Icon name = "edit" size = { 11 } / > Edit … < / button > }
{ canCancel && < button onClick = { onCancel } > < Icon name = "x" size = { 11 } / > Cancel run < / button > }
< button onClick = { onCopyId } > < Icon name = "library" size = { 11 } / > Copy schedule ID < / button >
{ canDelete && < div className = "ctx-divider" / > }
{ canDelete && < button className = "danger" onClick = { onDelete } > < Icon name = "trash" size = { 11 } / > Delete schedule < / button > }
< / div >
) ;
}
2026-05-23 15:48:09 -04:00
function _NowLine ( { now , dayStart , pph } ) {
if ( ! _sameDay ( now , dayStart ) ) return null ;
const min = _minutesIntoDay ( now , dayStart ) ;
const x = ( min / 60 ) * pph ;
return (
< div className = "epg-now" style = { { left : x } } >
< span className = "epg-now-pip" / >
< / div >
) ;
}
function _RecorderGutter ( { recorders , projects } ) {
return (
< div className = "epg-gutter-rows" >
{ recorders . map ( r => {
const color = _projectColor ( r . project _id , projects ) ;
const isLive = r . status === 'recording' ;
const isErr = r . status === 'error' ;
return (
< div key = { r . id } className = "epg-gutter-row" >
< span className = { 'epg-gutter-status ' + ( isLive ? 'live' : isErr ? 'err' : 'idle' ) } / >
< div className = "epg-gutter-meta" >
< div className = "epg-gutter-name" > { r . name } < / div >
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
< div className = "epg-gutter-sub mono" > { ( r . source _type || '·' ) . toUpperCase ( ) } { color ? ' · ' : '' } { color && < span className = "epg-gutter-dot" style = { { background : color } } / > } < / div >
2026-05-23 15:48:09 -04:00
< / div >
< / div >
) ;
} ) }
< / div >
) ;
}
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
function Schedule ( { navigate } ) {
2026-05-23 15:48:09 -04:00
const [ schedules , setSchedules ] = React . useState ( null ) ;
const [ recorders , setRecorders ] = React . useState ( [ ] ) ;
const [ showNew , setShowNew ] = React . useState ( false ) ;
const [ newDefaults , setNewDefaults ] = React . useState ( null ) ;
const [ editing , setEditing ] = React . useState ( null ) ;
2026-05-23 16:33:57 -04:00
const [ ctxMenu , setCtxMenu ] = React . useState ( null ) ; // { schedule, x, y }
2026-05-31 18:31:07 -04:00
const [ confirm , confirmModal ] = window . useConfirm ( ) ;
2026-05-23 15:48:09 -04:00
const [ view , setView ] = React . useState ( 'today' ) ; // 'today' | 'week' | 'list'
const [ day , setDay ] = React . useState ( ( ) => _dayStart ( new Date ( ) ) ) ;
const [ listFilter , setListFilter ] = React . useState ( 'upcoming' ) ;
const [ now , setNow ] = React . useState ( ( ) => new Date ( ) ) ;
// Tick the now-line every second. We only re-render the components that
// consume `now`; the rest are React.memo or insensitive.
React . useEffect ( ( ) => {
const id = setInterval ( ( ) => setNow ( new Date ( ) ) , 1000 ) ;
return ( ) => clearInterval ( id ) ;
} , [ ] ) ;
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// Schedule data - pull everything once and filter client-side for the
2026-05-23 15:48:09 -04:00
// active view. /schedules caps at 200 rows so this stays cheap.
const apiFilter = view === 'list' ? listFilter : 'all' ;
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
const load = React . useCallback ( ( ) => {
2026-05-23 14:52:04 -04:00
window . ZAMPP _API . fetch ( '/schedules?status=' + apiFilter )
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
. then ( d => setSchedules ( d . schedules || [ ] ) )
. catch ( ( ) => setSchedules ( [ ] ) ) ;
2026-05-23 14:52:04 -04:00
} , [ apiFilter ] ) ;
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
React . useEffect ( ( ) => { load ( ) ; } , [ load ] ) ;
2026-05-23 15:48:09 -04:00
React . useEffect ( ( ) => {
window . ZAMPP _API . fetch ( '/recorders' ) . then ( setRecorders ) . catch ( ( ) => setRecorders ( [ ] ) ) ;
} , [ ] ) ;
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
2026-05-23 15:48:09 -04:00
// Auto-refresh: 10s in normal view, 4s if anything is live so the now-state
// catches transitions promptly.
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
React . useEffect ( ( ) => {
2026-05-23 15:48:09 -04:00
const anyLive = ( schedules || [ ] ) . some ( s => s . status === 'running' ) ;
const id = setInterval ( load , anyLive ? 4000 : 10 _000 ) ;
return ( ) => clearInterval ( id ) ;
} , [ load , schedules ] ) ;
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
2026-05-23 15:48:09 -04:00
// The Recorders screen broadcasts these on create/delete; refresh the
// gutter so renamed or new recorders show up immediately.
React . useEffect ( ( ) => {
const refresh = ( ) => window . ZAMPP _API . fetch ( '/recorders' ) . then ( setRecorders ) . catch ( ( ) => { } ) ;
window . addEventListener ( 'df:recorders-changed' , refresh ) ;
return ( ) => window . removeEventListener ( 'df:recorders-changed' , refresh ) ;
} , [ ] ) ;
const projects = window . ZAMPP _DATA ? . PROJECTS || [ ] ;
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// Pixels per hour - wider on Today (high-res operations view), tighter
2026-05-23 15:48:09 -04:00
// when the user is scanning Week-at-a-glance.
const pph = view === 'week' ? 44 : 88 ;
const dayStart = _dayStart ( day ) ;
const dayEnd = _dayEnd ( day ) ;
// Scroll the canvas so "now" sits ~30% from the left edge on first paint
// for Today view. Re-runs when the user jumps days via the Today button.
const canvasRef = React . useRef ( null ) ;
React . useLayoutEffect ( ( ) => {
if ( view !== 'today' || ! canvasRef . current ) return ;
if ( ! _sameDay ( now , dayStart ) ) {
canvasRef . current . scrollLeft = 0 ;
return ;
2026-05-23 14:52:04 -04:00
}
2026-05-23 15:48:09 -04:00
const min = _minutesIntoDay ( now , dayStart ) ;
const x = ( min / 60 ) * pph ;
const target = Math . max ( 0 , x - canvasRef . current . clientWidth * 0.3 ) ;
canvasRef . current . scrollLeft = target ;
// Deliberately only re-run on view/day change, not on `now` ticking.
// Otherwise the canvas would re-scroll every second and trap the user.
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ view , day , pph , recorders . length ] ) ;
const openNewAt = ( recorder , start ) => {
2026-05-23 14:52:04 -04:00
const end = new Date ( start . getTime ( ) + 30 * 60 * 1000 ) ;
2026-05-23 15:48:09 -04:00
setNewDefaults ( { start , end , recorder _id : recorder . id } ) ;
2026-05-23 14:52:04 -04:00
setShowNew ( true ) ;
} ;
const openNewBlank = ( ) => { setNewDefaults ( null ) ; setShowNew ( true ) ; } ;
2026-05-31 18:31:07 -04:00
const cancel = async ( s ) => {
if ( ! ( await confirm ( { title : 'Cancel scheduled recording?' , message : 'Cancel scheduled recording "' + s . name + '"?' , confirmLabel : 'Cancel recording' } ) ) ) return ;
2026-05-23 15:48:09 -04:00
window . ZAMPP _API . fetch ( '/schedules/' + s . id + '/cancel' , { method : 'POST' } ) . then ( load ) . catch ( e => alert ( 'Cancel failed: ' + e . message ) ) ;
} ;
2026-05-31 18:31:07 -04:00
const remove = async ( s ) => {
if ( ! ( await confirm ( { title : 'Delete schedule?' , message : 'Delete schedule "' + s . name + '"?' } ) ) ) return ;
2026-05-23 15:48:09 -04:00
window . ZAMPP _API . fetch ( '/schedules/' + s . id , { method : 'DELETE' } ) . then ( load ) . catch ( e => alert ( 'Delete failed: ' + e . message ) ) ;
} ;
2026-05-23 16:33:57 -04:00
// Drag-resize commit: optimistically patch the in-memory schedule so the
// block stays put after the user lets go, then PUT the new times. The
// refetch reconciles in case the server adjusted anything (or rejected).
const handleResize = ( s , newStart , newEnd ) => {
setSchedules ( prev => prev ? prev . map ( x => x . id === s . id ? { ... x , start _at : newStart , end _at : newEnd } : x ) : prev ) ;
window . ZAMPP _API . fetch ( '/schedules/' + s . id , {
method : 'PUT' ,
body : JSON . stringify ( { start _at : newStart , end _at : newEnd } ) ,
} )
. then ( load )
. catch ( e => { alert ( 'Resize failed: ' + e . message ) ; load ( ) ; } ) ;
} ;
const openCtx = ( s , ev ) => setCtxMenu ( { schedule : s , x : ev . clientX , y : ev . clientY } ) ;
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// Dismiss the context menu on any outside click - capture phase so a
2026-05-23 16:33:57 -04:00
// click on a menu item still fires before the menu unmounts.
React . useEffect ( ( ) => {
if ( ! ctxMenu ) return ;
const close = ( ) => setCtxMenu ( null ) ;
window . addEventListener ( 'click' , close ) ;
window . addEventListener ( 'contextmenu' , close ) ;
window . addEventListener ( 'scroll' , close , true ) ;
return ( ) => {
window . removeEventListener ( 'click' , close ) ;
window . removeEventListener ( 'contextmenu' , close ) ;
window . removeEventListener ( 'scroll' , close , true ) ;
} ;
} , [ ctxMenu ] ) ;
const copyId = ( id ) => {
if ( navigator . clipboard ) navigator . clipboard . writeText ( id ) . catch ( ( ) => { } ) ;
} ;
2026-05-23 15:48:09 -04:00
// Days for Week view: the 7-day window starting at the Sunday of `day`.
const weekDays = React . useMemo ( ( ) => {
const sun = _addDays ( dayStart , - dayStart . getDay ( ) ) ;
return Array . from ( { length : 7 } , ( _ , i ) => _addDays ( sun , i ) ) ;
} , [ dayStart ] ) ;
// List view filters schedules by client-side time bucket too.
const listSchedules = React . useMemo ( ( ) => {
if ( ! schedules ) return null ;
return [ ... schedules ] . sort ( ( a , b ) => new Date ( a . start _at ) - new Date ( b . start _at ) ) ;
} , [ schedules ] ) ;
2026-05-23 14:52:04 -04:00
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
return (
2026-05-23 15:48:09 -04:00
< div className = "epg-page" style = { { '--epg-pph' : pph + 'px' } } >
2026-05-31 18:31:07 -04:00
{ confirmModal }
2026-05-23 15:48:09 -04:00
< _StatusStrip schedules = { schedules || [ ] } recorders = { recorders } now = { now } projects = { projects } / >
< div className = "epg-toolbar" >
< div className = "epg-date" >
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" onClick = { ( ) => setDay ( _addDays ( day , - 1 ) ) } title = "Previous day" aria - label = "Previous day" > < Icon name = "chevron" style = { { transform : 'rotate(90deg)' } } / > < / button >
2026-05-23 15:48:09 -04:00
< div className = "epg-date-label" > { _sameDay ( day , new Date ( ) ) ? 'Today · ' + _fmtDay ( day ) : _fmtDay ( day ) } < / div >
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" onClick = { ( ) => setDay ( _addDays ( day , 1 ) ) } title = "Next day" aria - label = "Next day" > < Icon name = "chevron" style = { { transform : 'rotate(-90deg)' } } / > < / button >
2026-05-23 15:48:09 -04:00
< button className = "btn ghost sm" onClick = { ( ) => setDay ( _dayStart ( new Date ( ) ) ) } disabled = { _sameDay ( day , new Date ( ) ) } > Today < / button >
< / div >
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< div className = "spacer" / >
2026-05-23 15:48:09 -04:00
< div className = "tab-group" >
< button className = { view === 'today' ? 'active' : '' } onClick = { ( ) => setView ( 'today' ) } > Today < / button >
< button className = { view === 'week' ? 'active' : '' } onClick = { ( ) => setView ( 'week' ) } > Week < / button >
< button className = { view === 'list' ? 'active' : '' } onClick = { ( ) => setView ( 'list' ) } > List < / button >
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< / div >
< button className = "btn ghost sm" onClick = { load } > < Icon name = "refresh" / > Refresh < / button >
2026-05-23 14:52:04 -04:00
< button className = "btn primary" onClick = { openNewBlank } disabled = { recorders . length === 0 } >
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< Icon name = "plus" / > New schedule
< / button >
< / div >
2026-05-23 15:48:09 -04:00
{ schedules === null && (
< div className = "epg-empty" > Loading … < / div >
) }
2026-05-23 14:52:04 -04:00
2026-05-23 15:48:09 -04:00
{ schedules !== null && recorders . length === 0 && (
< div className = "epg-empty" >
< div className = "epg-empty-title" > No recorders configured < / div >
< div className = "epg-empty-sub" > Create a recorder before scheduling . < / div >
< button className = "btn primary sm" onClick = { ( ) => navigate ( 'recorders' ) } > Go to Recorders < / button >
< / div >
) }
{ schedules !== null && recorders . length > 0 && view === 'today' && (
< div className = "epg" ref = { canvasRef } >
< div className = "epg-corner" >
< span className = "mono" > { _fmtDay ( day ) } < / span >
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< / div >
2026-05-23 15:48:09 -04:00
< div className = "epg-gutter" >
< _RecorderGutter recorders = { recorders } projects = { projects } / >
< / div >
< div className = "epg-canvas-head" >
< _EpgRuler pph = { pph } / >
< / div >
< div className = "epg-canvas" style = { { width : 24 * pph } } >
< div className = "epg-rows" >
{ recorders . map ( r => (
< _EpgRow key = { r . id }
recorder = { r }
schedules = { schedules }
dayStart = { dayStart }
dayEnd = { dayEnd }
pph = { pph }
now = { now }
projects = { projects }
onEventClick = { ( s ) => setEditing ( s ) }
2026-05-23 16:33:57 -04:00
onEventContextMenu = { openCtx }
onEventResize = { handleResize }
2026-05-23 15:48:09 -04:00
onEmptyClick = { openNewAt } / >
) ) }
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< / div >
2026-05-23 15:48:09 -04:00
< _NowLine now = { now } dayStart = { dayStart } pph = { pph } / >
< / div >
< / div >
) }
{ schedules !== null && recorders . length > 0 && view === 'week' && (
< div className = "epg-week" >
{ weekDays . map ( d => {
const dEnd = _dayEnd ( d ) ;
const isToday = _sameDay ( d , new Date ( ) ) ;
return (
< div key = { d . toISOString ( ) } className = { 'epg-week-day' + ( isToday ? ' today' : '' ) } >
< div className = "epg-week-dayhead" >
< span className = "epg-week-dayname" > { _fmtDay ( d ) } < / span >
{ isToday && < span className = "epg-week-todaypip" > today < / span > }
< / div >
< div className = "epg-week-row-wrap" style = { { width : 24 * pph } } >
< _EpgRuler pph = { pph } / >
< div className = "epg-rows" >
{ recorders . map ( r => (
< _EpgRow key = { r . id }
recorder = { r }
schedules = { schedules }
dayStart = { d }
dayEnd = { dEnd }
pph = { pph }
now = { now }
projects = { projects }
onEventClick = { ( s ) => setEditing ( s ) }
2026-05-23 16:33:57 -04:00
onEventContextMenu = { openCtx }
onEventResize = { handleResize }
2026-05-23 15:48:09 -04:00
onEmptyClick = { openNewAt } / >
) ) }
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< / div >
2026-05-23 15:48:09 -04:00
{ isToday && < _NowLine now = { now } dayStart = { d } pph = { pph } / > }
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< / div >
2026-05-23 15:48:09 -04:00
< / div >
) ;
} ) }
< / div >
) }
{ schedules !== null && recorders . length > 0 && view === 'list' && (
< div className = "epg-list" >
< div className = "tab-group" style = { { marginBottom : 12 } } >
< button className = { listFilter === 'upcoming' ? 'active' : '' } onClick = { ( ) => setListFilter ( 'upcoming' ) } > Upcoming < / button >
< button className = { listFilter === 'past' ? 'active' : '' } onClick = { ( ) => setListFilter ( 'past' ) } > Past < / button >
< button className = { listFilter === 'all' ? 'active' : '' } onClick = { ( ) => setListFilter ( 'all' ) } > All < / button >
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< / div >
2026-05-23 15:48:09 -04:00
{ ( listSchedules || [ ] ) . length === 0 ? (
< div className = "epg-empty" > < div className = "epg-empty-title" > No { listFilter } schedules < / div > < / div >
) : (
< div className = "panel" >
< div className = "schedule-row head" >
< div > Name < / div > < div > Recorder < / div > < div > Starts < / div > < div > Duration < / div > < div > Recurrence < / div > < div > Status < / div > < div > < / div >
< / div >
{ listSchedules . map ( s => {
const badge = _STATUS _BADGE [ s . status ] || _STATUS _BADGE . pending ;
return (
< div key = { s . id } className = "schedule-row" >
< div style = { { fontWeight : 500 , fontSize : 13 } } >
{ s . name }
{ s . error _message && < div style = { { fontSize : 11 , color : 'var(--danger)' , marginTop : 3 } } > { s . error _message } < / div > }
< / div >
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
< div className = "mono" style = { { fontSize : 11.5 , color : 'var(--text-2)' } } > { s . recorder _name || ( s . recorder _id ? s . recorder _id . slice ( 0 , 8 ) : 'unassigned' ) } < / div >
2026-05-23 15:48:09 -04:00
< div className = "mono" style = { { fontSize : 11.5 } } > { _fmtWhen ( s . start _at ) } < / div >
< div className = "mono" style = { { fontSize : 11.5 } } > { _durationMin ( s . start _at , s . end _at ) } min < / div >
< div className = "mono" style = { { fontSize : 11.5 , color : 'var(--text-3)' } } > { s . recurrence === 'none' ? 'one-shot' : s . recurrence } < / div >
< div > < span className = { 'badge ' + badge . cls } > { badge . label } < / span > < / div >
< div style = { { display : 'flex' , gap : 4 , justifyContent : 'flex-end' } } >
{ s . status === 'pending' && < button className = "btn ghost sm" onClick = { ( ) => setEditing ( s ) } > Edit < / button > }
{ ( s . status === 'pending' || s . status === 'running' ) && < button className = "btn ghost sm" onClick = { ( ) => cancel ( s ) } > Cancel < / button > }
{ ( s . status === 'completed' || s . status === 'failed' || s . status === 'cancelled' || s . status === 'pending' ) &&
< button className = "btn ghost sm" onClick = { ( ) => remove ( s ) } > Delete < / button > }
< / div >
< / div >
) ;
} ) }
< / div >
) }
< / div >
) }
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
2026-05-23 14:52:04 -04:00
{ showNew && < NewScheduleModal
recorders = { recorders }
2026-05-23 15:48:09 -04:00
defaultRecorderId = { newDefaults ? . recorder _id }
2026-05-23 14:52:04 -04:00
defaultStart = { newDefaults ? . start }
defaultEnd = { newDefaults ? . end }
onClose = { ( ) => { setShowNew ( false ) ; setNewDefaults ( null ) ; } }
onCreated = { ( ) => { setShowNew ( false ) ; setNewDefaults ( null ) ; load ( ) ; } } / > }
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
{ editing && < EditScheduleModal schedule = { editing } onClose = { ( ) => setEditing ( null ) } onSaved = { ( ) => { setEditing ( null ) ; load ( ) ; } } / > }
2026-05-23 16:33:57 -04:00
{ ctxMenu && (
< _ScheduleContextMenu
schedule = { ctxMenu . schedule }
x = { ctxMenu . x }
y = { ctxMenu . y }
onClose = { ( ) => setCtxMenu ( null ) }
onEdit = { ( ) => { const s = ctxMenu . schedule ; setCtxMenu ( null ) ; setEditing ( s ) ; } }
onCancel = { ( ) => { const s = ctxMenu . schedule ; setCtxMenu ( null ) ; cancel ( s ) ; } }
onDelete = { ( ) => { const s = ctxMenu . schedule ; setCtxMenu ( null ) ; remove ( s ) ; } }
onCopyId = { ( ) => { const id = ctxMenu . schedule . id ; setCtxMenu ( null ) ; copyId ( id ) ; } } / >
) }
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
< / div >
) ;
}
2026-05-23 15:48:09 -04:00
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
function EditScheduleModal ( { schedule , onClose , onSaved } ) {
const toLocalInput = ( iso ) => {
const d = new Date ( iso ) ;
const tz = d . getTimezoneOffset ( ) * 60 _000 ;
return new Date ( d . getTime ( ) - tz ) . toISOString ( ) . slice ( 0 , 16 ) ;
} ;
const [ form , setForm ] = React . useState ( {
name : schedule . name ,
start _at : toLocalInput ( schedule . start _at ) ,
end _at : toLocalInput ( schedule . end _at ) ,
recurrence : schedule . recurrence || 'none' ,
} ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ err , setErr ] = React . useState ( null ) ;
const set = ( k , v ) => setForm ( p => ( { ... p , [ k ] : v } ) ) ;
const submit = ( ) => {
setErr ( null ) ;
if ( ! form . name . trim ( ) ) return setErr ( 'Name is required' ) ;
const startD = new Date ( form . start _at ) ;
const endD = new Date ( form . end _at ) ;
if ( endD <= startD ) return setErr ( 'End must be after start' ) ;
setSaving ( true ) ;
window . ZAMPP _API . fetch ( '/schedules/' + schedule . id , {
method : 'PUT' ,
body : JSON . stringify ( {
name : form . name . trim ( ) ,
start _at : startD . toISOString ( ) ,
end _at : endD . toISOString ( ) ,
recurrence : form . recurrence ,
} ) ,
} )
. then ( onSaved )
. catch ( e => { setSaving ( false ) ; setErr ( e . message || 'Save failed' ) ; } ) ;
} ;
return (
< div className = "modal-backdrop" onClick = { onClose } >
< div className = "modal" style = { { width : 480 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "modal-head" >
< div style = { { fontSize : 15 , fontWeight : 600 } } > Edit scheduled recording < / div >
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 = "Close" onClick = { onClose } > < Icon name = "x" / > < / button >
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
< / div >
< div className = "modal-body" >
< div className = "field" >
< label className = "field-label" > Name < / label >
< input className = "field-input" autoFocus value = { form . name }
onChange = { e => set ( 'name' , e . target . value ) } / >
< / div >
< div className = "field" >
< label className = "field-label" > Recorder < / label >
< input className = "field-input mono" value = { schedule . recorder _name || schedule . recorder _id } readOnly
style = { { color : 'var(--text-3)' } } / >
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
< div className = "mono" style = { { fontSize : 11 , color : 'var(--text-3)' , marginTop : 4 } } > Recorder can ' t be reassigned : delete + recreate to change . < / div >
polish: schedule edit + README refresh
- Schedule: pending rows get an 'Edit' button next to Cancel/Delete;
opens a modal that PUTs /schedules/:id with new name/times/recurrence
(recorder reassignment is intentionally locked — delete + recreate
to swap recorder)
- README rewritten: project renamed to dragonflight, full feature
catalog (ingest, growing-files, scheduler, library + comments,
jobs, settings, cluster), accurate ports, refreshed architecture
diagram, ops scripts inventory
2026-05-23 00:26:03 -04:00
< / div >
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 } } >
< div className = "field" >
< label className = "field-label" > Start < / label >
< input className = "field-input mono" type = "datetime-local"
value = { form . start _at } onChange = { e => set ( 'start_at' , e . target . value ) } / >
< / div >
< div className = "field" >
< label className = "field-label" > End < / label >
< input className = "field-input mono" type = "datetime-local"
value = { form . end _at } onChange = { e => set ( 'end_at' , e . target . value ) } / >
< / div >
< / div >
< div className = "field" >
< label className = "field-label" > Recurrence < / label >
< select className = "field-input" value = { form . recurrence }
onChange = { e => set ( 'recurrence' , e . target . value ) }
style = { { appearance : 'auto' } } >
< option value = "none" > One - shot ( no repeat ) < / option >
< option value = "daily" > Daily < / option >
< option value = "weekly" > Weekly < / option >
< / select >
< / div >
{ err && < div style = { { fontSize : 12 , color : 'var(--danger)' , marginTop : 4 } } > { err } < / div > }
< / div >
< div className = "modal-foot" >
< button className = "btn ghost sm" onClick = { onClose } > Cancel < / button >
< button className = "btn primary sm" onClick = { submit } disabled = { saving } > { saving ? 'Saving…' : 'Save changes' } < / button >
< / div >
< / div >
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< / div >
) ;
}
2026-05-23 15:48:09 -04:00
function NewScheduleModal ( { recorders , onClose , onCreated , defaultStart , defaultEnd , defaultRecorderId } ) {
2026-05-23 14:52:04 -04:00
// If the user clicked a day on the calendar we honour that; otherwise default
// to "start in 5 minutes, run for 30 min" so the modal is immediately usable.
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
const toLocalInput = ( d ) => {
const tz = d . getTimezoneOffset ( ) * 60 _000 ;
return new Date ( d . getTime ( ) - tz ) . toISOString ( ) . slice ( 0 , 16 ) ;
} ;
2026-05-23 14:52:04 -04:00
const now = new Date ( ) ;
now . setSeconds ( 0 , 0 ) ;
const startDefault = defaultStart || new Date ( now . getTime ( ) + 5 * 60 * 1000 ) ;
const endDefault = defaultEnd || new Date ( startDefault . getTime ( ) + 30 * 60 * 1000 ) ;
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
const [ form , setForm ] = React . useState ( {
name : '' ,
2026-05-23 15:48:09 -04:00
recorder _id : defaultRecorderId || recorders [ 0 ] ? . id || '' ,
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
start _at : toLocalInput ( startDefault ) ,
end _at : toLocalInput ( endDefault ) ,
recurrence : 'none' ,
} ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ err , setErr ] = React . useState ( null ) ;
const set = ( k , v ) => setForm ( p => ( { ... p , [ k ] : v } ) ) ;
const submit = ( ) => {
setErr ( null ) ;
if ( ! form . name . trim ( ) ) return setErr ( 'Name is required' ) ;
if ( ! form . recorder _id ) return setErr ( 'Pick a recorder' ) ;
if ( ! form . start _at ) return setErr ( 'Start time is required' ) ;
if ( ! form . end _at ) return setErr ( 'End time is required' ) ;
2026-05-23 00:12:42 -04:00
const startD = new Date ( form . start _at ) ;
const endD = new Date ( form . end _at ) ;
if ( endD <= startD ) return setErr ( 'End must be after start' ) ;
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// Warn (but allow) start times in the past - the scheduler tick will fire
2026-05-23 00:12:42 -04:00
// them immediately, which is occasionally what the operator wants
// (e.g. "record the next 30 minutes starting now").
if ( startD < new Date ( Date . now ( ) - 60 _000 ) ) {
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
if ( ! confirm ( 'Start time is in the past: recorder will fire immediately when saved.\nContinue?' ) ) return ;
2026-05-23 00:12:42 -04:00
}
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
// Datetime-local inputs are in the browser's local zone; ship as ISO so
// Postgres stores them as TIMESTAMPTZ properly.
const body = {
name : form . name . trim ( ) ,
recorder _id : form . recorder _id ,
2026-05-23 00:12:42 -04:00
start _at : startD . toISOString ( ) ,
end _at : endD . toISOString ( ) ,
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
recurrence : form . recurrence ,
} ;
setSaving ( true ) ;
window . ZAMPP _API . fetch ( '/schedules' , { method : 'POST' , body : JSON . stringify ( body ) } )
. then ( ( ) => onCreated ( ) )
. catch ( e => { setSaving ( false ) ; setErr ( e . message || 'Failed to schedule' ) ; } ) ;
} ;
const onKey = ( e ) => { if ( e . key === 'Enter' && ! e . shiftKey ) submit ( ) ; } ;
return (
< div className = "modal-backdrop" onClick = { onClose } >
< div className = "modal" style = { { width : 480 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "modal-head" >
< div style = { { fontSize : 15 , fontWeight : 600 } } > New scheduled recording < / div >
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 = "Close" onClick = { onClose } > < Icon name = "x" / > < / button >
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
< / div >
< div className = "modal-body" >
< div className = "field" >
< label className = "field-label" > Name < / label >
< input className = "field-input" autoFocus value = { form . name }
onChange = { e => set ( 'name' , e . target . value ) }
onKeyDown = { onKey } placeholder = "Morning service stream" / >
< / div >
< div className = "field" >
< label className = "field-label" > Recorder < / label >
< select className = "field-input" value = { form . recorder _id }
onChange = { e => set ( 'recorder_id' , e . target . value ) }
style = { { appearance : 'auto' } } >
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.
## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)
## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)
## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
width on progress bar). Live DOM was 487 inline-styled elements due
to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
job-row-actions, job-row-status-* utility classes in styles-screens.css
## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor
## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)
## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing
## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
live recorders, 'Last 24 hours' tiles for newly created assets, plus
an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen
## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title
## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
{ recorders . length === 0 && < option value = "" > No recorders defined < / option > }
feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges
(pending / recording / completed / cancelled / failed), 10s
auto-refresh, cancel/delete actions
- New Schedule modal: name, recorder dropdown, datetime-local start/end,
recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min)
- Backend: migration 009 (recorder_schedules), routes/schedules.js
(list/create/edit/cancel/delete), scheduler.js tick loop polling every
15s; transitions trigger /recorders/:id/start and /stop via in-process
HTTP so we reuse the full container orchestration path
- Recurring schedules: tick loop auto-queues the next occurrence on
completion (daily = +24h, weekly = +7d)
- Sidebar + app.jsx route wired in, schedule-row table style added
2026-05-22 23:19:24 -04:00
{ recorders . map ( r => (
< option key = { r . id } value = { r . id } >
{ r . name } · { r . source _type ? . toUpperCase ( ) || '?' }
< / option >
) ) }
< / select >
< / div >
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 } } >
< div className = "field" >
< label className = "field-label" > Start < / label >
< input className = "field-input mono" type = "datetime-local"
value = { form . start _at }
onChange = { e => set ( 'start_at' , e . target . value ) } / >
< / div >
< div className = "field" >
< label className = "field-label" > End < / label >
< input className = "field-input mono" type = "datetime-local"
value = { form . end _at }
onChange = { e => set ( 'end_at' , e . target . value ) } / >
< / div >
< / div >
< div className = "field" >
< label className = "field-label" > Recurrence < / label >
< select className = "field-input" value = { form . recurrence }
onChange = { e => set ( 'recurrence' , e . target . value ) }
style = { { appearance : 'auto' } } >
< option value = "none" > One - shot ( no repeat ) < / option >
< option value = "daily" > Daily < / option >
< option value = "weekly" > Weekly < / option >
< / select >
< div style = { { fontSize : 11 , color : 'var(--text-3)' , marginTop : 4 } } >
Recurring schedules queue the next occurrence as soon as the current one completes .
< / div >
< / div >
{ err && < div style = { { fontSize : 12 , color : 'var(--danger)' , marginTop : 4 } } > { err } < / div > }
< / div >
< div className = "modal-foot" >
< button className = "btn ghost sm" onClick = { onClose } > Cancel < / button >
< button className = "btn primary sm" onClick = { submit } disabled = { saving } >
{ saving ? 'Scheduling…' : 'Schedule recording' }
< / button >
< / div >
< / div >
< / div >
) ;
}
2026-05-23 16:05:41 -04:00
Object . assign ( window , { Upload , Recorders , Capture , Monitors , Schedule , YouTubeImport } ) ;