2026-05-22 08:22:38 -04:00
// screens-admin.jsx — Users, Tokens, Containers, Cluster (graph), Settings
2026-05-22 10:15:42 -04:00
function _normalizeNode ( n , x , y ) {
feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 14:06:30 -04:00
const cap = n . capabilities || { } ;
// GPUs: capabilities.gpus entries with name+memory_mb = driver-bound (nvidia-smi confirmed).
// Entries with only type+device = detected by /dev file but driver status unknown.
const gpus = ( cap . gpus || [ ] ) . map ( g => ( {
name : g . name || ( g . type ? g . type . toUpperCase ( ) : 'GPU' ) ,
memMb : g . memory _mb || null ,
index : g . index ? ? 0 ,
device : g . device || null ,
bound : ! ! ( g . name && g . memory _mb ) , // name+memory = nvidia-smi confirmed driver bound
} ) ) ;
// Blackmagic DeckLink: capabilities.blackmagic + capabilities.blackmagic_model
const bmdPorts = ( cap . blackmagic || [ ] ) . map ( b => ( {
index : b . index ? ? 0 ,
device : b . device || null ,
model : cap . blackmagic _model || null ,
online : b . online !== false ,
} ) ) ;
const memUsedMb = n . mem _used _mb || n . memory _used _mb || ( n . mem && n . mem < 1000 ? n . mem * 1024 : n . mem || 0 ) ;
const memTotalMb = n . mem _total _mb || n . memory _total _mb || ( n . memTotal && n . memTotal < 1000 ? n . memTotal * 1024 : n . memTotal || 0 ) ;
2026-05-22 10:15:42 -04:00
return {
feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 14:06:30 -04:00
id : n . hostname || n . id || n . name || 'node' ,
dbId : n . id ,
role : n . role || 'worker' ,
status : n . status || ( n . online ? 'online' : 'offline' ) ,
ip : n . ip _address || n . ip || '—' ,
version : n . version || '—' ,
uptime : n . uptime || '—' ,
cpu : parseFloat ( n . cpu _usage || n . cpu || n . cpu _percent || 0 ) ,
mem : Math . round ( memUsedMb / 1024 * 10 ) / 10 ,
memTotal : Math . round ( memTotalMb / 1024 * 10 ) / 10 ,
// Raw capabilities for the hardware panel
gpus ,
bmdPorts ,
// Legacy flat arrays kept for the stat-row summary cards
gpuCount : gpus . length ,
bmdCount : bmdPorts . length ,
2026-05-22 10:15:42 -04:00
x , y ,
} ;
}
2026-05-22 08:22:38 -04:00
2026-05-22 12:27:02 -04:00
function InviteUserModal ( { onCreated , onClose } ) {
2026-05-26 23:28:05 -04:00
const [ form , setForm ] = React . useState ( { username : '' , display _name : '' , password : '' , role : 'viewer' } ) ;
2026-05-22 12:27:02 -04:00
const [ saving , setSaving ] = React . useState ( false ) ;
const [ err , setErr ] = React . useState ( null ) ;
const submit = ( ) => {
if ( ! form . username || ! form . password ) { setErr ( 'Username and password are required' ) ; return ; }
setSaving ( true ) ; setErr ( null ) ;
2026-05-26 23:28:05 -04:00
window . ZAMPP _API . fetch ( '/users' , { method : 'POST' , body : JSON . stringify ( form ) } )
2026-05-22 12:27:02 -04:00
. then ( user => { onCreated ( user ) ; onClose ( ) ; } )
. catch ( e => { setSaving ( false ) ; setErr ( e . message || 'Failed to create user' ) ; } ) ;
} ;
const onKey = e => { if ( e . key === 'Enter' ) submit ( ) ; } ;
return (
< div className = "modal-backdrop" onClick = { onClose } >
< div className = "modal" style = { { width : 420 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "modal-head" >
< div style = { { fontSize : 15 , fontWeight : 600 } } > Invite user < / 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 >
2026-05-22 12:27:02 -04:00
< / div >
< div className = "modal-body" >
< div className = "field" >
< label className = "field-label" > Username < / label >
< input className = "field-input" value = { form . username } autoFocus
onChange = { e => setForm ( p => ( { ... p , username : e . target . value } ) ) }
onKeyDown = { onKey } placeholder = "jsmith" / >
< / div >
< div className = "field" >
< label className = "field-label" > Display name < / label >
< input className = "field-input" value = { form . display _name }
onChange = { e => setForm ( p => ( { ... p , display _name : e . target . value } ) ) }
onKeyDown = { onKey } placeholder = "John Smith" / >
< / div >
< div className = "field" >
< label className = "field-label" > Password < / label >
< input className = "field-input" type = "password" value = { form . password }
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
autoComplete = "new-password"
2026-05-22 12:27:02 -04:00
onChange = { e => setForm ( p => ( { ... p , password : e . target . value } ) ) }
onKeyDown = { onKey } placeholder = "Temporary password" / >
< / div >
< div className = "field" >
< label className = "field-label" > Role < / label >
< select className = "field-input" value = { form . role }
2026-05-26 23:28:05 -04:00
onChange = { e => setForm ( p => ( { ... p , role : e . target . value } ) ) }
2026-05-22 12:27:02 -04:00
style = { { appearance : 'auto' } } >
< option value = "viewer" > Viewer < / option >
< option value = "editor" > Editor < / option >
< option value = "admin" > Admin < / 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 ? 'Creating…' : 'Create user' } < / button >
< / div >
< / div >
< / div >
) ;
}
2026-05-22 08:22:38 -04:00
function Users ( ) {
2026-05-22 23:30:10 -04:00
const [ users , setUsers ] = React . useState ( window . ZAMPP _DATA . USERS || [ ] ) ;
const [ groups , setGroups ] = React . useState ( [ ] ) ;
const [ tab , setTab ] = React . useState ( "users" ) ;
2026-05-22 12:27:02 -04:00
const [ showInvite , setShowInvite ] = React . useState ( false ) ;
2026-05-22 23:30:10 -04:00
const [ editingUser , setEditingUser ] = React . useState ( null ) ;
2026-05-23 09:07:56 -04:00
const [ resetUser , setResetUser ] = React . useState ( null ) ;
2026-05-22 23:30:10 -04:00
const [ menuFor , setMenuFor ] = React . useState ( null ) ; // row id whose menu is open
const refreshUsers = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/users' )
. then ( list => {
const normalized = ( list || [ ] ) . map ( u => ( {
... u ,
name : u . display _name || u . username ,
initials : ( u . display _name || u . username || '??' ) . slice ( 0 , 2 ) . toUpperCase ( ) ,
group _count : u . group _count ? ? 0 ,
} ) ) ;
setUsers ( normalized ) ;
window . ZAMPP _DATA . USERS = normalized ;
} )
. catch ( ( ) => { } ) ;
} , [ ] ) ;
const refreshGroups = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/groups' )
. then ( list => setGroups ( list || [ ] ) )
. catch ( ( ) => setGroups ( [ ] ) ) ;
} , [ ] ) ;
React . useEffect ( ( ) => { refreshUsers ( ) ; refreshGroups ( ) ; } , [ refreshUsers , refreshGroups ] ) ;
// Click-outside closes any open row menu so the user can dismiss it without picking.
React . useEffect ( ( ) => {
if ( ! menuFor ) return ;
const close = ( ) => setMenuFor ( null ) ;
window . addEventListener ( 'click' , close ) ;
return ( ) => window . removeEventListener ( 'click' , close ) ;
} , [ menuFor ] ) ;
2026-05-22 12:27:02 -04:00
const exportCsv = ( ) => {
2026-05-26 23:28:05 -04:00
const rows = [ [ 'Username' , 'Name' , 'Role' , 'Groups' , 'Created' ] ] . concat (
users . map ( u => [ u . username || '' , u . name || '' , u . role || '' , u . group _count || 0 , u . created _at || '' ] )
2026-05-22 12:27:02 -04:00
) ;
const csv = rows . map ( r => r . map ( c => '"' + String ( c ) . replace ( /"/g , '""' ) + '"' ) . join ( ',' ) ) . join ( '\n' ) ;
const a = document . createElement ( 'a' ) ;
a . href = 'data:text/csv;charset=utf-8,' + encodeURIComponent ( csv ) ;
a . download = 'users.csv' ;
a . click ( ) ;
} ;
2026-05-22 23:30:10 -04:00
const onCreated = ( ) => { refreshUsers ( ) ; setShowInvite ( false ) ; } ;
const deleteUser = ( u ) => {
setMenuFor ( null ) ;
if ( ! confirm ( ` Delete user " ${ u . name } " (@ ${ u . username } )? \ nThis cannot be undone. ` ) ) return ;
window . ZAMPP _API . fetch ( '/users/' + u . id , { method : 'DELETE' } )
. then ( refreshUsers )
. catch ( e => alert ( 'Delete failed: ' + e . message ) ) ;
} ;
2026-05-23 09:07:56 -04:00
const resetPassword = ( u ) => { setMenuFor ( null ) ; setResetUser ( u ) ; } ;
2026-05-22 23:30:10 -04:00
const changeRole = ( u , newRole ) => {
if ( u . role === newRole ) return ;
2026-05-26 23:28:05 -04:00
window . ZAMPP _API . fetch ( '/users/' + u . id , { method : 'PATCH' , body : JSON . stringify ( { role : newRole } ) } )
2026-05-22 23:30:10 -04:00
. then ( refreshUsers )
. catch ( e => alert ( 'Role change failed: ' + e . message ) ) ;
2026-05-22 12:27:02 -04:00
} ;
2026-05-22 08:22:38 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Users & amp ; Groups < / h1 >
< div className = "spacer" / >
2026-05-22 23:30:10 -04:00
{ tab === 'users' && ( < >
< button className = "btn ghost sm" onClick = { exportCsv } > < Icon name = "download" / > Export < / button >
< button className = "btn primary" onClick = { ( ) => setShowInvite ( true ) } > < Icon name = "plus" / > Invite user < / button >
< / > ) }
2026-05-22 08:22:38 -04:00
< / div >
< div className = "page-body" >
< div className = "tab-group" style = { { width : "fit-content" , marginBottom : 12 } } >
2026-05-22 12:27:02 -04:00
< button className = { tab === "users" ? "active" : "" } onClick = { ( ) => setTab ( "users" ) } > Users · { users . length } < / button >
2026-05-22 23:30:10 -04:00
< button className = { tab === "groups" ? "active" : "" } onClick = { ( ) => setTab ( "groups" ) } > Groups · { groups . length } < / button >
2026-05-22 10:15:42 -04:00
< button className = { tab === "policies" ? "active" : "" } onClick = { ( ) => setTab ( "policies" ) } > Policies < / button >
2026-05-22 08:22:38 -04:00
< / div >
2026-05-22 23:30:10 -04:00
{ tab === 'users' && (
< div className = "panel" >
< div className = "user-row head" >
< div > User < / div >
< div > Role < / div >
< div > Groups < / div >
2026-05-26 23:28:05 -04:00
< div > Created < / div >
2026-05-22 23:30:10 -04:00
< div > < / div >
< / div >
{ users . length === 0 && (
< div style = { { padding : "32px 0" , textAlign : "center" , color : "var(--text-3)" } } > No users found < / div >
) }
{ users . map ( u => (
< div key = { u . id } className = "user-row" style = { { position : 'relative' } } >
< div style = { { display : "flex" , alignItems : "center" , gap : 10 } } >
< div className = "avatar" style = { { width : 32 , height : 32 , fontSize : 11 , background : avatarColor ( u . initials || u . id ) } } > { u . initials || '??' } < / div >
< div >
< div style = { { fontWeight : 500 , fontSize : 13 } } > { u . name } < / div >
< div className = "mono" style = { { fontSize : 11 , color : "var(--text-3)" } } > @ { u . username } < / div >
< / div >
< / div >
2026-05-22 08:22:38 -04:00
< div >
2026-05-22 23:30:10 -04:00
< select value = { u . role || 'viewer' }
onChange = { e => changeRole ( u , e . target . value ) }
className = "field-input"
style = { { width : 90 , padding : '3px 6px' , fontSize : 11.5 , appearance : 'auto' } } >
< option value = "admin" > admin < / option >
< option value = "editor" > editor < / option >
< option value = "viewer" > viewer < / option >
< / select >
< / div >
< div className = "mono" style = { { fontSize : 11.5 , color : "var(--text-3)" } } >
{ u . group _count || 0 } { u . group _count === 1 ? 'group' : 'groups' }
< / div >
< div className = "mono" style = { { fontSize : 11.5 , color : "var(--text-3)" } } >
2026-05-26 23:28:05 -04:00
{ u . created _at ? new Date ( u . created _at ) . toLocaleDateString ( ) : u . lastSeen || '—' }
2026-05-22 23:30:10 -04:00
< / div >
< div style = { { position : 'relative' } } >
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 = "User actions" onClick = { e => { e . stopPropagation ( ) ; setMenuFor ( menuFor === u . id ? null : u . id ) ; } } >
2026-05-22 23:30:10 -04:00
< Icon name = "more" / >
< / button >
{ menuFor === u . id && (
< div className = "row-menu" onClick = { e => e . stopPropagation ( ) } >
< button onClick = { ( ) => { setMenuFor ( null ) ; setEditingUser ( u ) ; } } >
< Icon name = "edit" size = { 12 } / > Rename
< / button >
< button onClick = { ( ) => resetPassword ( u ) } >
< Icon name = "key" size = { 12 } / > Reset password
< / button >
< button className = "danger" onClick = { ( ) => deleteUser ( u ) } >
< Icon name = "trash" size = { 12 } / > Delete
< / button >
< / div >
) }
2026-05-22 08:22:38 -04:00
< / div >
< / div >
2026-05-22 23:30:10 -04:00
) ) }
< / div >
) }
{ tab === 'groups' && < GroupsPanel groups = { groups } users = { users } onChange = { refreshGroups } / > }
{ tab === 'policies' && (
< div className = "panel" style = { { padding : '48px 24px' , textAlign : 'center' , color : 'var(--text-3)' } } >
< Icon name = "lock" size = { 24 } / >
< div style = { { marginTop : 10 , fontWeight : 500 , fontSize : 14 , color : 'var(--text-2)' } } > Access policies < / div >
< div style = { { fontSize : 12 , marginTop : 4 } } >
Per - project and per - bin permissions are coming soon . For now , role - based access < br / >
( admin / editor / viewer ) is enforced API - wide .
2026-05-22 08:22:38 -04:00
< / div >
2026-05-22 23:30:10 -04:00
< / div >
) }
2026-05-22 08:22:38 -04:00
< / div >
2026-05-22 12:27:02 -04:00
{ showInvite && < InviteUserModal onCreated = { onCreated } onClose = { ( ) => setShowInvite ( false ) } / > }
2026-05-22 23:30:10 -04:00
{ editingUser && (
< EditUserModal user = { editingUser }
onClose = { ( ) => setEditingUser ( null ) }
onSaved = { ( ) => { setEditingUser ( null ) ; refreshUsers ( ) ; } }
/ >
) }
2026-05-23 09:07:56 -04:00
{ resetUser && (
< PasswordResetModal user = { resetUser }
onClose = { ( ) => setResetUser ( null ) }
onSaved = { ( ) => setResetUser ( null ) }
/ >
) }
2026-05-22 23:30:10 -04:00
< / div >
) ;
}
function EditUserModal ( { user , onClose , onSaved } ) {
const [ name , setName ] = React . useState ( user . display _name || user . name || '' ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ err , setErr ] = React . useState ( null ) ;
const submit = ( ) => {
setSaving ( true ) ; setErr ( null ) ;
window . ZAMPP _API . fetch ( '/users/' + user . id , { method : 'PATCH' , body : JSON . stringify ( { display _name : name . trim ( ) } ) } )
. then ( onSaved )
. catch ( e => { setSaving ( false ) ; setErr ( e . message ) ; } ) ;
} ;
return (
< div className = "modal-backdrop" onClick = { onClose } >
< div className = "modal" style = { { width : 400 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "modal-head" >
< div style = { { fontSize : 15 , fontWeight : 600 } } > Rename user < / 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 >
2026-05-22 23:30:10 -04:00
< / div >
< div className = "modal-body" >
< div className = "field" >
< label className = "field-label" > Display name < / label >
< input className = "field-input" autoFocus value = { name }
onChange = { e => setName ( e . target . value ) }
onKeyDown = { e => { if ( e . key === 'Enter' ) submit ( ) ; } } / >
< div className = "mono" style = { { fontSize : 11 , color : 'var(--text-3)' , marginTop : 4 } } > username @ { user . username } cannot be changed < / 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 || ! name . trim ( ) } > { saving ? 'Saving…' : 'Save' } < / button >
< / div >
< / div >
< / div >
) ;
}
2026-05-23 09:07:56 -04:00
function PasswordResetModal ( { user , onClose , onSaved } ) {
const [ pw , setPw ] = React . useState ( '' ) ;
const [ pw2 , setPw2 ] = React . useState ( '' ) ;
const [ show , setShow ] = React . useState ( false ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ err , setErr ] = React . useState ( null ) ;
const [ done , setDone ] = React . useState ( false ) ;
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
// #111 — guard async resolution / delayed onSaved against unmount.
const mountedRef = React . useRef ( true ) ;
const savedTimerRef = React . useRef ( null ) ;
React . useEffect ( ( ) => ( ) => {
mountedRef . current = false ;
if ( savedTimerRef . current ) clearTimeout ( savedTimerRef . current ) ;
} , [ ] ) ;
2026-05-23 09:07:56 -04:00
const valid = pw . length >= 8 && pw === pw2 ;
const submit = ( ) => {
if ( ! valid ) return ;
setSaving ( true ) ; setErr ( null ) ;
window . ZAMPP _API . fetch ( '/users/' + user . id , { method : 'PATCH' , body : JSON . stringify ( { password : pw } ) } )
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
. then ( ( ) => {
if ( ! mountedRef . current ) return ;
setSaving ( false ) ; setDone ( true ) ;
savedTimerRef . current = setTimeout ( ( ) => { if ( mountedRef . current ) onSaved ( ) ; } , 1200 ) ;
} )
. catch ( e => { if ( mountedRef . current ) { setSaving ( false ) ; setErr ( e . message ) ; } } ) ;
2026-05-23 09:07:56 -04:00
} ;
return (
< div className = "modal-backdrop" onClick = { onClose } >
< div className = "modal" style = { { width : 400 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "modal-head" >
< div style = { { fontSize : 15 , fontWeight : 600 } } > Reset password · @ { user . username } < / 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 >
2026-05-23 09:07:56 -04:00
< / div >
< div className = "modal-body" >
{ done ? (
< div style = { { textAlign : 'center' , padding : '12px 0' , color : 'var(--success)' , fontSize : 13 } } >
< Icon name = "check" size = { 16 } / > Password updated .
< / div >
) : ( < >
< div className = "field" >
< label className = "field-label" > New password < / label >
< div style = { { position : 'relative' } } >
< input className = "field-input" autoFocus type = { show ? 'text' : 'password' }
value = { pw } onChange = { e => setPw ( e . target . value ) }
onKeyDown = { e => { if ( e . key === 'Enter' && valid ) submit ( ) ; } }
style = { { paddingRight : 36 } } / >
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 = { show ? 'Hide password' : 'Show password' } style = { { position : 'absolute' , right : 4 , top : '50%' , transform : 'translateY(-50%)' } }
2026-05-23 09:07:56 -04:00
onClick = { ( ) => setShow ( s => ! s ) } type = "button" tabIndex = { - 1 } >
< Icon name = { show ? 'eye-off' : 'eye' } size = { 13 } / >
< / button >
< / div >
< div style = { { fontSize : 11 , color : pw . length > 0 && pw . length < 8 ? 'var(--danger)' : 'var(--text-3)' , marginTop : 4 } } >
Minimum 8 characters
< / div >
< / div >
< div className = "field" >
< label className = "field-label" > Confirm password < / label >
< input className = "field-input" type = { show ? 'text' : 'password' }
value = { pw2 } onChange = { e => setPw2 ( e . target . value ) }
onKeyDown = { e => { if ( e . key === 'Enter' && valid ) submit ( ) ; } } / >
{ pw2 . length > 0 && pw !== pw2 && (
< div style = { { fontSize : 11 , color : 'var(--danger)' , marginTop : 4 } } > Passwords do not match < / div >
) }
< / div >
{ err && < div style = { { fontSize : 12 , color : 'var(--danger)' , marginTop : 4 } } > { err } < / div > }
< / > ) }
< / div >
{ ! done && (
< div className = "modal-foot" >
< button className = "btn ghost sm" onClick = { onClose } > Cancel < / button >
< button className = "btn primary sm" onClick = { submit } disabled = { saving || ! valid } >
{ saving ? 'Saving…' : 'Reset password' }
< / button >
< / div >
) }
< / div >
< / div >
) ;
}
2026-05-22 23:30:10 -04:00
function GroupsPanel ( { groups , users , onChange } ) {
const [ creating , setCreating ] = React . useState ( false ) ;
const [ newName , setNewName ] = React . useState ( '' ) ;
const [ newDesc , setNewDesc ] = React . useState ( '' ) ;
const [ expandedId , setExpandedId ] = React . useState ( null ) ;
const [ members , setMembers ] = React . useState ( { } ) ; // groupId -> [user]
const createGroup = ( ) => {
if ( ! newName . trim ( ) ) return ;
window . ZAMPP _API . fetch ( '/groups' , { method : 'POST' , body : JSON . stringify ( { name : newName . trim ( ) , description : newDesc . trim ( ) || null } ) } )
. then ( ( ) => { setCreating ( false ) ; setNewName ( '' ) ; setNewDesc ( '' ) ; onChange ( ) ; } )
. catch ( e => alert ( 'Create failed: ' + e . message ) ) ;
} ;
const deleteGroup = ( g ) => {
if ( ! confirm ( ` Delete group " ${ g . name } "? ` ) ) return ;
window . ZAMPP _API . fetch ( '/groups/' + g . id , { method : 'DELETE' } )
. then ( onChange )
. catch ( e => alert ( 'Delete failed: ' + e . message ) ) ;
} ;
const toggle = ( g ) => {
if ( expandedId === g . id ) { setExpandedId ( null ) ; return ; }
setExpandedId ( g . id ) ;
window . ZAMPP _API . fetch ( '/groups/' + g . id + '/members' )
. then ( list => setMembers ( m => ( { ... m , [ g . id ] : list || [ ] } ) ) )
. catch ( ( ) => setMembers ( m => ( { ... m , [ g . id ] : [ ] } ) ) ) ;
} ;
const addMember = ( g , userId ) => {
if ( ! userId ) return ;
window . ZAMPP _API . fetch ( '/groups/' + g . id + '/members' , { method : 'POST' , body : JSON . stringify ( { user _id : userId } ) } )
. then ( ( ) => {
window . ZAMPP _API . fetch ( '/groups/' + g . id + '/members' )
. then ( list => setMembers ( m => ( { ... m , [ g . id ] : list || [ ] } ) ) ) ;
onChange ( ) ;
} )
. catch ( e => alert ( 'Add failed: ' + e . message ) ) ;
} ;
const removeMember = ( g , uid ) => {
window . ZAMPP _API . fetch ( '/groups/' + g . id + '/members/' + uid , { method : 'DELETE' } )
. then ( ( ) => {
setMembers ( m => ( { ... m , [ g . id ] : ( m [ g . id ] || [ ] ) . filter ( u => u . id !== uid ) } ) ) ;
onChange ( ) ;
} )
. catch ( e => alert ( 'Remove failed: ' + e . message ) ) ;
} ;
return (
< div >
< div style = { { display : 'flex' , alignItems : 'center' , marginBottom : 12 } } >
< div style = { { flex : 1 , fontSize : 12 , color : 'var(--text-3)' } } >
Groups let you bundle users for project access . Memberships are checked when role - based access is enforced .
< / div >
< button className = "btn primary sm" onClick = { ( ) => setCreating ( true ) } > < Icon name = "plus" size = { 11 } / > New group < / button >
< / div >
{ creating && (
< div className = "panel" style = { { padding : 12 , marginBottom : 12 , display : 'grid' , gridTemplateColumns : '1fr 2fr auto auto' , gap : 8 , alignItems : 'end' } } >
< div className = "field" style = { { marginBottom : 0 } } >
< label className = "field-label" > Group name < / label >
< input className = "field-input" autoFocus value = { newName } onChange = { e => setNewName ( e . target . value ) }
onKeyDown = { e => { if ( e . key === 'Enter' ) createGroup ( ) ; } } placeholder = "broadcasters" / >
< / div >
< div className = "field" style = { { marginBottom : 0 } } >
< label className = "field-label" > Description ( optional ) < / label >
< input className = "field-input" value = { newDesc } onChange = { e => setNewDesc ( e . target . value ) }
onKeyDown = { e => { if ( e . key === 'Enter' ) createGroup ( ) ; } } placeholder = "On-air operators" / >
< / div >
< button className = "btn primary sm" onClick = { createGroup } disabled = { ! newName . trim ( ) } > Create < / button >
< button className = "btn ghost sm" onClick = { ( ) => { setCreating ( false ) ; setNewName ( '' ) ; setNewDesc ( '' ) ; } } > Cancel < / button >
< / div >
) }
< div className = "panel" >
{ groups . length === 0 && ! creating && (
< div style = { { padding : '32px 0' , textAlign : 'center' , color : 'var(--text-3)' , fontSize : 12.5 } } >
No groups yet — click < em > New group < / em > above to create one .
< / div >
) }
{ groups . map ( g => {
const isOpen = expandedId === g . id ;
const groupMembers = members [ g . id ] || [ ] ;
const nonMembers = users . filter ( u => ! groupMembers . some ( m => m . id === u . id ) ) ;
return (
< div key = { g . id } style = { { borderBottom : '1px solid var(--border)' } } >
< div style = { { padding : '12px 16px' , display : 'grid' , gridTemplateColumns : '1.6fr 2fr 90px 80px' , alignItems : 'center' , gap : 12 } } >
< div >
< div style = { { fontWeight : 500 , fontSize : 13 } } > { g . name } < / div >
< div className = "mono" style = { { fontSize : 11 , color : 'var(--text-3)' } } > { g . id . slice ( 0 , 8 ) } < / div >
< / div >
< div style = { { fontSize : 12 , color : 'var(--text-3)' } } > { g . description || < span style = { { fontStyle : 'italic' } } > no description < / span > } < / div >
< div className = "mono" style = { { fontSize : 11.5 , color : 'var(--text-3)' } } > { g . member _count || 0 } member { g . member _count === 1 ? '' : 's' } < / div >
< div style = { { display : 'flex' , gap : 4 , justifyContent : 'flex-end' } } >
< button className = "btn ghost sm" onClick = { ( ) => toggle ( g ) } > { isOpen ? 'Hide' : 'Members' } < / button >
< button className = "btn ghost sm danger" onClick = { ( ) => deleteGroup ( g ) } > Delete < / button >
< / div >
< / div >
{ isOpen && (
< div style = { { padding : '0 16px 16px 16px' , background : 'var(--bg-2)' } } >
< div style = { { display : 'flex' , gap : 6 , flexWrap : 'wrap' , marginBottom : 10 } } >
{ groupMembers . length === 0 && < span style = { { fontSize : 12 , color : 'var(--text-3)' } } > No members yet . < / span > }
{ groupMembers . map ( m => (
< span key = { m . id } className = "badge outline" style = { { display : 'inline-flex' , alignItems : 'center' , gap : 6 , padding : '4px 8px' } } >
@ { m . username }
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
< button className = "icon-btn" aria - label = "Remove member" style = { { width : 16 , height : 16 , padding : 0 } } onClick = { ( ) => removeMember ( g , m . id ) } title = "Remove" >
2026-05-22 23:30:10 -04:00
< Icon name = "x" size = { 9 } / >
< / button >
< / span >
) ) }
< / div >
{ nonMembers . length > 0 && (
< div style = { { display : 'flex' , gap : 8 , alignItems : 'center' } } >
< span style = { { fontSize : 11.5 , color : 'var(--text-3)' } } > Add member : < / span >
< select className = "field-input" defaultValue = ""
onChange = { e => { addMember ( g , e . target . value ) ; e . target . value = '' ; } }
style = { { width : 200 , padding : '4px 8px' , fontSize : 12 , appearance : 'auto' } } >
< option value = "" disabled > — Pick a user — < / option >
{ nonMembers . map ( u => < option key = { u . id } value = { u . id } > @ { u . username } — { u . name } < / option > ) }
< / select >
< / div >
) }
< / div >
) }
< / div >
) ;
} ) }
< / div >
2026-05-22 08:22:38 -04:00
< / div >
) ;
}
function Tokens ( ) {
const [ burned , setBurned ] = React . useState ( 14340 ) ;
const [ rate , setRate ] = React . useState ( 2.4 ) ;
2026-05-23 16:18:00 -04:00
const [ showCalc , setShowCalc ] = React . useState ( false ) ;
2026-05-22 08:22:38 -04:00
React . useEffect ( ( ) => {
const i = setInterval ( ( ) => {
setBurned ( b => b + Math . floor ( Math . random ( ) * 8 ) + 1 ) ;
setRate ( r => Math . max ( 0.8 , Math . min ( 8 , r + ( Math . random ( ) - 0.5 ) * 0.4 ) ) ) ;
} , 800 ) ;
return ( ) => clearInterval ( i ) ;
} , [ ] ) ;
const burnSpark = React . useMemo ( ( ) => Array . from ( { length : 40 } , ( _ , i ) => 100 + i * 8 + Math . sin ( i * 0.7 ) * 12 ) , [ ] ) ;
const competitorSpark = React . useMemo ( ( ) => Array . from ( { length : 40 } , ( _ , i ) => 200 + i * 22 + Math . cos ( i * 0.5 ) * 30 ) , [ ] ) ;
const yourCostSpark = React . useMemo ( ( ) => Array . from ( { length : 40 } , ( ) => 1 ) , [ ] ) ;
const [ events , setEvents ] = React . useState ( [
{ t : "21:14:02" , action : "preview thumbnail generated" , cost : 4 } ,
{ t : "21:14:01" , action : "user clicked play" , cost : 12 } ,
{ t : "21:13:58" , action : "API health check" , cost : 8 } ,
{ t : "21:13:54" , action : "asset metadata read" , cost : 2 } ,
{ t : "21:13:51" , action : "session token refreshed" , cost : 18 } ,
{ t : "21:13:47" , action : "scrubbed timeline 1 frame" , cost : 6 } ,
{ t : "21:13:42" , action : "took a deep breath near the API" , cost : 24 } ,
] ) ;
React . useEffect ( ( ) => {
const actions = [
"preview thumbnail generated" , "user clicked play" , "API health check" ,
"scrubbed timeline 1 frame" , "asset metadata read" , "session token refreshed" ,
"checked job queue" , "rendered a tooltip" , "loaded sidebar icon" ,
"blinked" , "made eye contact with the cluster" , "opened a modal (twice)" ,
"asset list pagination request" , "thought about a comment" , "moved cursor near 'Save'" ,
] ;
const i = setInterval ( ( ) => {
const now = new Date ( ) ;
const t = ` ${ String ( now . getHours ( ) ) . padStart ( 2 , "0" ) } : ${ String ( now . getMinutes ( ) ) . padStart ( 2 , "0" ) } : ${ String ( now . getSeconds ( ) ) . padStart ( 2 , "0" ) } ` ;
const a = actions [ Math . floor ( Math . random ( ) * actions . length ) ] ;
const c = Math . floor ( Math . random ( ) * 28 ) + 1 ;
setEvents ( ev => [ { t , action : a , cost : c } , ... ev ] . slice ( 0 , 12 ) ) ;
} , 1600 ) ;
return ( ) => clearInterval ( i ) ;
} , [ ] ) ;
const tiers = [
{ name : "Starter" , desc : "For \"evaluation only\" — definitely not production" , price : "$2,400" , per : "/ month" , tokens : "100k tokens" , popular : false , color : "#6B7280" } ,
{ name : "Broadcast" , desc : "Most teams. Most pain." , price : "$28,000" , per : "/ month" , tokens : "1.5M tokens" , popular : true , color : "#5B7CFA" } ,
{ name : "Enterprise" , desc : "If you have to ask, you can't afford it (but you'll ask anyway)" , price : "$call us" , per : "and bring lawyers" , tokens : "∞ tokens" , popular : false , color : "#B57CFA" } ,
] ;
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Tokens < / h1 >
< span className = "subtitle" > Token - metered pricing parody · You actually pay < strong style = { { color : "var(--success)" } } > $0 .00 < / strong > < / span >
< div className = "spacer" / >
< span className = "badge warning" > < Icon name = "alert" size = { 10 } / > SATIRE < / span >
< button className = "btn ghost sm" onClick = { ( ) => setShowCalc ( ! showCalc ) } > < Icon name = "sliders" / > Cost calculator < / button >
< / div >
< div className = "page-body" >
2026-05-22 17:29:23 -04:00
< div style = { { textAlign : 'center' , padding : '8px 0 36px' } } >
< h2 style = { { fontSize : 30 , fontWeight : 700 , letterSpacing : '-0.02em' , lineHeight : 1.3 , margin : 0 } } >
< span style = { { textDecoration : 'line-through' , color : 'var(--text-3)' , fontWeight : 500 } } > Per - seat < / span >
{ ' · ' }
< span style = { { textDecoration : 'line-through' , color : 'var(--text-3)' , fontWeight : 500 } } > Per - stream < / span >
{ ' · ' }
< span style = { { textDecoration : 'line-through' , color : 'var(--text-3)' , fontWeight : 500 } } > Per - month < / span >
< br / >
< span style = { { fontSize : 52 , fontWeight : 800 , color : 'var(--accent-text)' , letterSpacing : '-0.03em' } } > Per Token . < / span >
< / h2 >
< / div >
2026-05-22 08:22:38 -04:00
< div className = "token-hero" >
< div className = "token-burn-card" >
< div className = "token-card-label" > TOKENS BURNED THIS SESSION < / div >
< div className = "token-counter" >
< span className = "token-flame" > 🔥 < / span >
< span className = "token-big mono" > { burned . toLocaleString ( ) } < / span >
< / div >
< div className = "token-rate" >
< span className = "mono" style = { { color : "var(--danger)" } } > ↑ { rate . toFixed ( 1 ) } k / sec < / span >
< span style = { { color : "var(--text-3)" , marginLeft : 10 } } > burning since you logged in < / span >
< / div >
< div style = { { marginTop : 12 } } >
< Sparkline data = { burnSpark } color = "#FF5B5B" / >
< / div >
< / div >
< div className = "token-actual-card" >
< div className = "token-card-label" > WHAT YOU ACTUALLY PAY < / div >
< div className = "token-actual-amount" >
< span style = { { fontSize : 48 , fontWeight : 700 , letterSpacing : "-0.04em" } } > $0 < / span >
< span style = { { fontSize : 18 , color : "var(--text-3)" } } > .00 < / span >
< / div >
< div style = { { fontSize : 12 , color : "var(--text-3)" , lineHeight : 1.5 } } >
Dragonflight is self - hosted . The tokens above are imaginary . < br / >
Imagine them as a stress test for your sanity .
< / div >
< div style = { { marginTop : 12 } } >
< Sparkline data = { yourCostSpark } color = "#2DD4A8" fill = { false } / >
< / div >
< / div >
< / div >
< div className = "token-comparison" >
< div className = "token-card-label" style = { { padding : "16px 16px 0" } } > HOURLY BURN — DRAGONFLIGHT vs . THE OTHER GUYS < / div >
< div className = "token-compare-chart" >
< ChartLine
series = { [
{ label : "AMPP-style competitor" , data : competitorSpark , color : "#FF5B5B" } ,
{ label : "Dragonflight (yours)" , data : yourCostSpark . map ( ( _ , i ) => i < 20 ? 1 : 1 ) , color : "#2DD4A8" } ,
] }
/ >
< div className = "token-compare-legend" >
< div > < span className = "dot" style = { { background : "#FF5B5B" } } / > Competitor : $1 , 247 / hr and rising < / div >
< div > < span className = "dot" style = { { background : "#2DD4A8" } } / > Dragonflight : $0 .00 / hr forever < / div >
< / div >
< / div >
< / div >
< div className = "token-grid" >
< div >
< div className = "token-card-label" style = { { marginBottom : 8 } } > LIVE BILLING EVENTS < / div >
< div className = "panel" >
{ events . map ( ( e , i ) => (
< div key = { i } className = { ` token-event ${ i === 0 ? "fresh" : "" } ` } >
< span className = "mono" style = { { color : "var(--text-3)" , fontSize : 11 } } > { e . t } < / span >
< span style = { { flex : 1 , fontSize : 12.5 } } > { e . action } < / span >
< span className = "mono" style = { { color : "var(--danger)" , fontWeight : 600 } } > + { e . cost } tk < / span >
< / div >
) ) }
< / div >
< / div >
< div >
< div className = "token-card-label" style = { { marginBottom : 8 } } > PRICING TIERS WE DIDN ' T COPY < / div >
< div className = "token-tiers" >
{ tiers . map ( t => (
< div key = { t . name } className = { ` token-tier ${ t . popular ? "popular" : "" } ` } >
{ t . popular && < span className = "token-tier-badge" > MOST PAIN < / span > }
< div className = "token-tier-name" style = { { color : t . color } } > { t . name } < / div >
< div className = "token-tier-desc" > { t . desc } < / div >
< div className = "token-tier-price" >
< span style = { { fontSize : 26 , fontWeight : 700 , letterSpacing : "-0.02em" } } > { t . price } < / span >
< span style = { { fontSize : 12 , color : "var(--text-3)" , marginLeft : 4 } } > { t . per } < / span >
< / div >
< div className = "token-tier-tokens mono" > { t . tokens } < / div >
< button className = "btn subtle sm" disabled style = { { width : "100%" , marginTop : 8 } } > Not for sale < / button >
< / div >
) ) }
< / div >
< / div >
< / div >
{ showCalc && < CostCalculator onClose = { ( ) => setShowCalc ( false ) } / > }
< div className = "token-footnote" >
< Icon name = "alert" size = { 14 } / >
< div >
< strong > Disclaimer : < / strong > No actual tokens were billed in the making of this page . Wild Dragon ' s broadcast platform
is self - hosted infrastructure . Any resemblance to per - API - call broadcast token economies , living or dead , is satirical
and protected as commentary . If you came here looking for actual API tokens , that page no longer exists — service
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
credentials are managed through the cluster ' s own JWT issuer .
2026-05-22 08:22:38 -04:00
< / div >
< / div >
< / div >
< / div >
) ;
}
function ChartLine ( { series } ) {
const w = 600 , h = 140 ;
return (
< svg viewBox = { ` 0 0 ${ w } ${ h } ` } preserveAspectRatio = "none" style = { { width : "100%" , height : 140 } } >
< defs >
< pattern id = "cgrid" width = "60" height = "28" patternUnits = "userSpaceOnUse" >
< path d = "M 60 0 L 0 0 0 28" fill = "none" stroke = "rgba(255,255,255,0.04)" strokeWidth = "1" / >
< / pattern >
< / defs >
< rect width = { w } height = { h } fill = "url(#cgrid)" / >
{ series . map ( ( s , si ) => {
const max = Math . max ( ... series . flatMap ( x => x . data ) , 1 ) ;
const pts = s . data . map ( ( d , i ) => {
const x = ( i / ( s . data . length - 1 ) ) * w ;
const y = h - ( d / max ) * ( h - 10 ) - 4 ;
return ` ${ x } , ${ y } ` ;
} ) . join ( " " ) ;
const area = ` 0, ${ h } ${ pts } ${ w } , ${ h } ` ;
return (
< g key = { si } >
< polygon points = { area } fill = { s . color } opacity = "0.1" / >
< polyline points = { pts } fill = "none" stroke = { s . color } strokeWidth = "2" / >
< circle cx = { w } cy = { h - ( s . data [ s . data . length - 1 ] / max ) * ( h - 10 ) - 4 } r = "4" fill = { s . color } / >
< circle cx = { w } cy = { h - ( s . data [ s . data . length - 1 ] / max ) * ( h - 10 ) - 4 } r = "8" fill = { s . color } opacity = "0.3" >
< animate attributeName = "r" values = "4;14;4" dur = "2s" repeatCount = "indefinite" / >
< animate attributeName = "opacity" values = "0.6;0;0.6" dur = "2s" repeatCount = "indefinite" / >
< / circle >
< / g >
) ;
} ) }
< / svg >
) ;
}
function CostCalculator ( { onClose } ) {
const [ users , setUsers ] = React . useState ( 12 ) ;
const [ assets , setAssets ] = React . useState ( 500 ) ;
const [ clicks , setClicks ] = React . useState ( 2000 ) ;
const cost = users * 240 + assets * 8 + clicks * 0.12 ;
return (
< div className = "modal-backdrop" onClick = { onClose } >
< div className = "modal" style = { { width : 520 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "modal-head" >
< div >
< div style = { { fontSize : 15 , fontWeight : 600 } } > Token Cost Calculator < / div >
< div style = { { fontSize : 12 , color : "var(--text-3)" , marginTop : 2 } } > What it would cost on AMPP - style pricing < / 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
< button className = "icon-btn" aria - label = "Close" onClick = { onClose } > < Icon name = "x" / > < / button >
2026-05-22 08:22:38 -04:00
< / div >
< div className = "modal-body" >
< CalcSlider label = "Users" value = { users } onChange = { setUsers } min = { 1 } max = { 100 } unit = " people" / >
< CalcSlider label = "Assets in library" value = { assets } onChange = { setAssets } min = { 50 } max = { 10000 } step = { 50 } unit = "" / >
< CalcSlider label = "UI clicks per day" value = { clicks } onChange = { setClicks } min = { 100 } max = { 20000 } step = { 100 } unit = "" / >
< div style = { { background : "var(--bg-2)" , border : "1px solid var(--border)" , borderRadius : 8 , padding : 16 , marginTop : 8 } } >
< div style = { { fontSize : 11 , color : "var(--text-3)" , textTransform : "uppercase" , letterSpacing : "0.06em" , fontWeight : 600 } } > You would be paying < / div >
< div style = { { fontSize : 36 , fontWeight : 700 , color : "var(--danger)" , letterSpacing : "-0.02em" , marginTop : 4 } } >
$ { cost . toLocaleString ( "en-US" , { maximumFractionDigits : 0 } ) } < span style = { { fontSize : 14 , color : "var(--text-3)" , fontWeight : 400 , marginLeft : 4 } } > / month < / span >
< / div >
< div style = { { marginTop : 8 , padding : 10 , background : "var(--success-soft)" , borderRadius : 6 , fontSize : 12.5 , color : "var(--success)" } } >
< strong > Your actual Dragonflight cost : < / strong > $0 .00 . You ' re welcome .
< / div >
< / div >
< / div >
< / div >
< / div >
) ;
}
function CalcSlider ( { label , value , onChange , min , max , step = 1 , unit } ) {
return (
< div >
< div style = { { display : "flex" , justifyContent : "space-between" , marginBottom : 6 , fontSize : 12 } } >
< span style = { { color : "var(--text-2)" } } > { label } < / span >
< span className = "mono" style = { { color : "var(--text-1)" , fontWeight : 600 } } > { value . toLocaleString ( ) } { unit } < / span >
< / div >
< input
type = "range"
min = { min } max = { max } step = { step }
value = { value }
onChange = { e => onChange ( Number ( e . target . value ) ) }
style = { { width : "100%" , accentColor : "var(--accent)" } }
/ >
< / div >
) ;
}
function Containers ( ) {
2026-05-22 10:15:42 -04:00
const [ containers , setContainers ] = React . useState ( null ) ;
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 [ restartFlashState , setRestartFlashState ] = React . useState ( null ) ;
const [ logsModalState , setLogsModalState ] = React . useState ( null ) ;
// #111 — guard restart-flash timers against unmount.
const mountedRef = React . useRef ( true ) ;
const flashTimerRef = React . useRef ( null ) ;
React . useEffect ( ( ) => ( ) => {
mountedRef . current = false ;
if ( flashTimerRef . current ) clearTimeout ( flashTimerRef . current ) ;
} , [ ] ) ;
const setRestartFlashSafe = ( v ) => { if ( mountedRef . current ) setRestartFlashState ( v ) ; } ;
const scheduleFlashClear = ( ms ) => {
if ( flashTimerRef . current ) clearTimeout ( flashTimerRef . current ) ;
flashTimerRef . current = setTimeout ( ( ) => setRestartFlashSafe ( null ) , ms ) ;
} ;
2026-05-22 10:15:42 -04:00
function load ( ) {
setContainers ( null ) ;
window . ZAMPP _API . fetch ( '/cluster/containers' )
. then ( data => setContainers ( Array . isArray ( data ) ? data : ( data . containers || [ ] ) ) )
. catch ( ( ) => setContainers ( [ ] ) ) ;
}
React . useEffect ( ( ) => { load ( ) ; } , [ ] ) ;
const running = ( containers || [ ] ) . filter ( c => c . state === 'running' ) . length ;
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 restartFlash = restartFlashState ;
const logsModal = logsModalState ;
const setLogsModal = setLogsModalState ;
2026-05-23 00:02:51 -04:00
const showLogs = ( c ) => setLogsModal ( c ) ;
2026-05-22 12:27:02 -04:00
const restartContainer = ( c ) => {
2026-05-23 00:02:51 -04:00
if ( ! window . confirm ( 'Restart container "' + c . name + '"?\nIn-flight requests will be dropped.' ) ) 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
setRestartFlashSafe ( { name : c . name , status : 'pending' } ) ;
2026-05-22 12:27:02 -04:00
window . ZAMPP _API . fetch ( '/cluster/containers/' + encodeURIComponent ( c . id || c . name ) + '/restart' , { method : 'POST' } )
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
. then ( ( ) => {
if ( ! mountedRef . current ) return ;
setRestartFlashSafe ( { name : c . name , status : 'ok' } ) ;
load ( ) ;
scheduleFlashClear ( 3000 ) ;
} )
. catch ( e => {
setRestartFlashSafe ( { name : c . name , status : 'fail' , error : e . message } ) ;
scheduleFlashClear ( 5000 ) ;
} ) ;
2026-05-22 12:27:02 -04:00
} ;
2026-05-22 08:22:38 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Containers < / h1 >
< span className = "subtitle" > Docker Compose services across the cluster < / span >
< div className = "spacer" / >
2026-05-22 10:15:42 -04:00
{ containers !== null && containers . length > 0 && (
< div className = "status-pip" >
< span className = "dot" / >
< span > { running } / { containers . length } running < / span >
< / div >
) }
< button className = "btn ghost sm" onClick = { load } > < Icon name = "refresh" / > Refresh < / button >
2026-05-22 08:22:38 -04:00
< / div >
2026-05-23 00:02:51 -04:00
{ restartFlash && (
< div style = { {
position : 'fixed' , bottom : 20 , right : 20 , zIndex : 200 ,
background : restartFlash . status === 'ok' ? 'var(--success-soft)'
: restartFlash . status === 'fail' ? 'var(--danger-soft)'
: 'var(--bg-2)' ,
color : restartFlash . status === 'ok' ? 'var(--success)'
: restartFlash . status === 'fail' ? 'var(--danger)' : 'var(--text-2)' ,
border : '1px solid' , borderColor : restartFlash . status === 'ok' ? 'var(--success)'
: restartFlash . status === 'fail' ? 'var(--danger)' : 'var(--border)' ,
borderRadius : 6 , padding : '10px 14px' , fontSize : 12.5 , maxWidth : 320 ,
} } >
{ restartFlash . status === 'pending' && ` Restarting ${ restartFlash . name } … ` }
{ restartFlash . status === 'ok' && ` ${ restartFlash . name } restarted. ` }
{ restartFlash . status === 'fail' && ` ${ restartFlash . name } : ${ restartFlash . error } ` }
< / div >
) }
{ logsModal && (
< div className = "modal-backdrop" onClick = { ( ) => setLogsModal ( null ) } >
< div className = "modal" style = { { width : 540 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "modal-head" >
< div style = { { fontSize : 15 , fontWeight : 600 } } > Logs · { logsModal . name } < / 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 = { ( ) => setLogsModal ( null ) } > < Icon name = "x" / > < / button >
2026-05-23 00:02:51 -04:00
< / div >
< div className = "modal-body" >
< div style = { { fontSize : 12 , color : 'var(--text-3)' , marginBottom : 8 } } >
Live log streaming over the websocket isn ' t wired yet . SSH to the host that runs this container and tail the logs directly :
< / div >
< code className = "mono" style = { { display : 'block' , background : 'var(--bg-2)' , padding : 10 , borderRadius : 5 , fontSize : 11.5 , overflowX : 'auto' } } >
docker compose logs - f { logsModal . name }
< / code >
< div style = { { fontSize : 11.5 , color : 'var(--text-3)' , marginTop : 10 } } >
Or grab the last 200 lines : & nbsp ;
< span className = "mono" > docker logs -- tail 200 { logsModal . name } < / span >
< / div >
< / div >
< div className = "modal-foot" >
< button className = "btn ghost sm" onClick = { ( ) => {
if ( navigator . clipboard ) navigator . clipboard . writeText ( 'docker compose logs -f ' + logsModal . name ) . catch ( ( ) => { } ) ;
} } > Copy command < / button >
< button className = "btn primary sm" onClick = { ( ) => setLogsModal ( null ) } > Close < / button >
< / div >
< / div >
< / div >
) }
2026-05-22 08:22:38 -04:00
< div className = "page-body" >
2026-05-22 10:15:42 -04:00
{ containers === null && (
< div style = { { textAlign : 'center' , padding : '64px 0' , color : 'var(--text-3)' } } > Loading … < / div >
) }
{ containers !== null && containers . length === 0 && (
< div style = { { textAlign : 'center' , padding : '64px 0' , color : 'var(--text-3)' } } >
< div style = { { fontSize : 32 , marginBottom : 12 } } > 🐳 < / div >
2026-05-23 09:07:56 -04:00
< div style = { { fontWeight : 500 , fontSize : 14 } } > No containers returned < / div >
< div style = { { fontSize : 12 , marginTop : 6 } } > Confirm < code > / var / r u n / d o c k e r . s o c k < / c o d e > i s m o u n t e d i n t h e m a m - a p i c o n t a i n e r < / d i v >
2026-05-22 08:22:38 -04:00
< / div >
2026-05-22 10:15:42 -04:00
) }
{ containers !== null && containers . length > 0 && (
< div className = "panel" >
< div className = "container-row head" >
< div > Container < / div >
< div > Image < / div >
< div > State < / div >
< div > CPU < / div >
< div > Memory < / div >
< div > Ports < / div >
< div > < / div >
< / div >
{ containers . map ( c => (
< div key = { c . id || c . name } className = "container-row" >
< div >
< div style = { { fontWeight : 500 , fontSize : 13 } } > { c . name } < / div >
< div className = "mono" style = { { fontSize : 11 , color : "var(--text-3)" } } > up { c . uptime } < / div >
< / div >
< div className = "mono" style = { { fontSize : 11.5 , color : "var(--text-2)" } } > { c . image } < / div >
< div >
< span className = "badge success" > < StatusDot status = "online" / > RUNNING < / span >
{ c . healthy && < span style = { { fontSize : 10.5 , color : "var(--success)" , marginLeft : 6 } } > healthy < / span > }
< / div >
< div className = "mono" style = { { fontSize : 11.5 } } >
< div style = { { display : "flex" , alignItems : "center" , gap : 6 } } >
< div style = { { width : 40 , height : 4 , background : "var(--bg-3)" , borderRadius : 99 , overflow : "hidden" } } >
< div style = { { width : ` ${ Math . min ( ( c . cpu || 0 ) * 4 , 100 ) } % ` , height : "100%" , background : "var(--accent)" } } / >
< / div >
< span > { ( c . cpu || 0 ) . toFixed ( 1 ) } % < / span >
2026-05-22 08:22:38 -04:00
< / div >
2026-05-22 10:15:42 -04:00
< / div >
< div className = "mono" style = { { fontSize : 11.5 } } > { c . mem } MB < / div >
< div className = "mono" style = { { fontSize : 10.5 , color : "var(--text-3)" } } > { c . ports } < / div >
< div style = { { display : "flex" , gap : 4 } } >
2026-05-22 12:27:02 -04:00
< button className = "btn ghost sm" onClick = { ( ) => showLogs ( c ) } > Logs < / button >
< button className = "btn ghost sm" onClick = { ( ) => restartContainer ( c ) } > Restart < / button >
2026-05-22 08:22:38 -04:00
< / div >
< / div >
2026-05-22 10:15:42 -04:00
) ) }
< / div >
) }
2026-05-22 08:22:38 -04:00
< / div >
< / div >
) ;
}
2026-05-26 18:02:38 -04:00
// ────────────────────────────────────────────────────────────────────────────
// BmdCardPanel — capture-card section inside the Cluster node detail panel.
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
// ────────────────────────────────────────────────────────────────────────────
function BmdCardPanel ( { sel , portSignals } ) {
const svgRef = React . useRef ( null ) ;
// Build the port-index → signal-entry map for the selected node.
const nodeSignalMap = React . useMemo ( ( ) => {
const map = new Map ( ) ;
sel . bmdPorts . forEach ( ( p ) => {
const key = ` ${ sel . dbId } : ${ p . index } ` ;
const entry = portSignals [ key ] ;
if ( entry ) map . set ( p . index , entry . signal ) ;
} ) ;
return map ;
} , [ sel . dbId , sel . bmdPorts , portSignals ] ) ;
// (Re-)render the SVG card diagram whenever the node or signals change.
React . useEffect ( ( ) => {
if ( ! svgRef . current || ! window . BMDCards ) return ;
if ( sel . bmdPorts . length === 0 ) return ;
svgRef . current . innerHTML = '' ;
const svg = window . BMDCards . render ( {
model : sel . bmdPorts [ 0 ] . model || '' ,
deviceCount : sel . bmdCount ,
compact : true ,
portSignals : nodeSignalMap ,
} ) ;
svgRef . current . appendChild ( svg ) ;
} , [ sel . dbId , sel . bmdCount , nodeSignalMap ] ) ;
return (
< div >
< div style = { { fontSize : 11 , color : "var(--text-3)" , marginBottom : 6 , display : "flex" , alignItems : "center" , gap : 6 } } >
< Icon name = "video" size = { 11 } / >
Capture cards { sel . bmdCount > 0 ? ` ( ${ sel . bmdCount } port ${ sel . bmdCount !== 1 ? 's' : '' } ) ` : '— none reported' }
< / div >
{ sel . bmdPorts . length === 0 && (
< div style = { { fontSize : 11.5 , color : "var(--text-4)" , padding : "4px 0" } } > No DeckLink cards detected on this node < / div >
) }
{ sel . bmdPorts . length > 0 && (
< div style = { { padding : "8px 10px" , background : "var(--bg-2)" , borderRadius : 5 , border : "1px solid rgba(91,124,250,0.2)" } } >
{ /* Card header */ }
< div style = { { display : "flex" , alignItems : "center" , gap : 8 , marginBottom : 8 } } >
< Icon name = "video" size = { 13 } style = { { color : "var(--accent)" } } / >
< span style = { { fontSize : 12 , fontWeight : 600 , color : "var(--text-1)" } } >
{ sel . bmdPorts [ 0 ] . model || "Blackmagic DeckLink" }
< / span >
< span style = { { marginLeft : "auto" , fontSize : 10 , fontWeight : 600 , padding : "2px 6px" , borderRadius : 3 , background : "rgba(91,124,250,0.15)" , color : "var(--accent)" } } >
{ sel . bmdCount } PORT { sel . bmdCount !== 1 ? 'S' : '' }
< / span >
< / div >
{ /* Port chips with signal state */ }
< div style = { { display : "flex" , flexWrap : "wrap" , gap : 4 , marginBottom : 10 } } >
{ sel . bmdPorts . map ( ( p ) => {
const sigEntry = portSignals [ ` ${ sel . dbId } : ${ p . index } ` ] ;
const sig = sigEntry ? sigEntry . signal : ( p . online !== false ? null : 'offline' ) ;
const { label , color } = _signalChip ( sig ) ;
const isReceiving = sig === 'receiving' ;
return (
< div key = { p . index } title = { sigEntry ? ` ${ sigEntry . recorder _name || 'recorder' } — ${ label } ` : label }
style = { {
display : "flex" , alignItems : "center" , gap : 5 ,
fontSize : 10.5 , fontFamily : "var(--font-mono)" ,
padding : "3px 8px" , borderRadius : 3 ,
background : isReceiving ? "rgba(45,212,168,0.1)" : "rgba(255,255,255,0.04)" ,
border : ` 1px solid ${ isReceiving ? "rgba(45,212,168,0.3)" : "var(--border)" } ` ,
} } >
{ /* Signal presence dot */ }
< span style = { {
width : 6 , height : 6 , borderRadius : "50%" , flexShrink : 0 ,
background : sig ? color : "var(--text-4)" ,
animation : isReceiving ? "signalPulse 1.4s ease-in-out infinite" : "none" ,
} } / >
< span style = { { color : "var(--text-2)" } } >
{ p . device ? p . device . split ( '/' ) . pop ( ) : ` port ${ p . index } ` }
< / span >
{ sig && (
< span style = { { color , fontSize : 9 , fontWeight : 700 , marginLeft : 2 , letterSpacing : "0.04em" } } >
{ label }
< / span >
) }
{ sigEntry && sigEntry . currentFps != null && (
< span style = { { color : "var(--text-4)" , fontSize : 9 } } >
{ Number ( sigEntry . currentFps ) . toFixed ( 1 ) } fps
< / span >
) }
< / div >
) ;
} ) }
< / div >
{ /* BMD SVG card diagram */ }
< div ref = { svgRef } className = "bmd-card-diagram" / >
< / div >
) }
< / div >
) ;
}
// Signal state → { label, color } for the port chip indicator.
function _signalChip ( sig ) {
switch ( sig ) {
case 'receiving' : return { label : 'RECEIVING' , color : 'var(--success)' } ;
case 'connecting' : return { label : 'CONNECTING' , color : 'var(--accent)' } ;
case 'lost' : return { label : 'LOST' , color : 'var(--danger)' } ;
case 'error' : return { label : 'ERROR' , color : 'var(--danger)' } ;
case 'idle' : return { label : 'IDLE' , color : 'var(--text-3)' } ;
case 'no-recorder' : return { label : 'NO RECORDER' , color : 'var(--text-4)' } ;
default : return { label : sig || '—' , color : 'var(--text-4)' } ;
}
}
2026-05-22 08:22:38 -04:00
function Cluster ( ) {
2026-05-22 12:27:02 -04:00
const [ nodesData , setNodesData ] = React . useState ( window . ZAMPP _DATA . NODES ) ;
const [ hovered , setHovered ] = React . useState ( null ) ;
2026-05-26 18:02:38 -04:00
// Map of "node_id:portIndex" → signal entry from /cluster/devices/blackmagic/signal
const [ portSignals , setPortSignals ] = React . useState ( { } ) ;
2026-05-22 12:27:02 -04:00
const refresh = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/cluster' )
. then ( data => {
window . ZAMPP _DATA . NODES = data ;
setNodesData ( data ) ;
} )
. catch ( ( ) => { } ) ;
} , [ ] ) ;
2026-05-26 18:02:38 -04:00
// Poll live video-presence state for all DeckLink ports every 5 s.
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 ) ;
} )
. catch ( ( ) => { } ) ;
} ;
poll ( ) ;
const id = setInterval ( poll , 5000 ) ;
return ( ) => clearInterval ( id ) ;
} , [ ] ) ;
2026-05-22 12:27:02 -04:00
const nodesArr = Array . isArray ( nodesData ) ? nodesData : ( nodesData ? . nodes || [ ] ) ;
2026-05-22 10:15:42 -04:00
const NODES = React . useMemo ( ( ) => {
if ( ! nodesArr . length ) return [ ] ;
const primaryRaw = nodesArr . find ( n => n . role === 'primary' ) || nodesArr [ 0 ] ;
const others = nodesArr . filter ( n => n !== primaryRaw ) ;
const primary = _normalizeNode ( primaryRaw , 0.5 , 0.46 ) ;
const positioned = others . map ( ( n , i ) => {
const angle = others . length <= 1
? Math . PI / 2
: ( i / others . length ) * 2 * Math . PI - Math . PI / 2 ;
return _normalizeNode ( n , 0.5 + 0.32 * Math . cos ( angle ) , 0.46 + 0.35 * Math . sin ( angle ) ) ;
} ) ;
return [ primary , ... positioned ] ;
2026-05-22 12:27:02 -04:00
} , [ nodesData ] ) ;
2026-05-22 10:15:42 -04:00
2026-05-22 12:27:02 -04:00
const [ selected , setSelected ] = React . useState ( null ) ;
const sel = selected || NODES [ 0 ] || null ;
2026-05-22 08:22:38 -04:00
const W = 720 , H = 460 ;
2026-05-22 10:15:42 -04:00
if ( ! NODES . length ) {
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Cluster < / h1 >
< div className = "spacer" / >
2026-05-22 12:27:02 -04:00
< button className = "btn ghost sm" onClick = { refresh } > < Icon name = "refresh" / > Refresh < / button >
2026-05-22 10:15:42 -04:00
< / div >
< div className = "page-body" >
< div style = { { textAlign : 'center' , padding : '64px 0' , color : 'var(--text-3)' } } > No cluster nodes available < / div >
< / div >
< / div >
) ;
}
const primary = NODES . find ( n => n . role === 'primary' ) || NODES [ 0 ] ;
2026-05-22 08:22:38 -04:00
const edges = NODES . filter ( n => n . id !== primary . id ) . map ( n => ( {
from : primary ,
to : n ,
2026-05-22 10:15:42 -04:00
alive : n . status === 'online' ,
2026-05-22 08:22:38 -04:00
} ) ) ;
2026-05-22 12:27:02 -04:00
2026-05-23 00:02:51 -04:00
const [ adviceModal , setAdviceModal ] = React . useState ( null ) ; // {title, lines:[], commands?}
const addNode = ( ) => setAdviceModal ( {
title : 'Add a worker node' ,
lines : [
'Worker nodes auto-register with the cluster on first heartbeat.' ,
'Run these on the new host (replace 10.0.0.25 with this MAM\'s IP):' ,
] ,
commands : [
'git clone https://forge.wilddragon.net/zgaetano/dragonflight.git /opt/dragonflight' ,
'cd /opt/dragonflight && cp .env.example .env && nano .env # set NODE_ROLE=worker, point at MAM IP' ,
'docker compose -f docker-compose.worker.yml up -d' ,
] ,
} ) ;
const drainNode = ( node ) => setAdviceModal ( {
title : ` Drain ${ node . id } ` ,
lines : [
'Automated drain isn\'t implemented yet. The safe sequence is:' ,
'1. Stop scheduling new jobs to this node (kill its node-agent).' ,
'2. Let in-progress jobs finish.' ,
'3. Remove the node from cluster membership.' ,
] ,
commands : [ ` ssh ${ node . ip || node . id } 'docker stop node-agent' ` ] ,
} ) ;
2026-05-22 12:27:02 -04:00
const removeNode = ( node ) => {
if ( ! window . confirm ( 'Remove node ' + node . id + ' from the cluster?\nThis does not stop the machine — it only removes it from cluster membership.' ) ) return ;
feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 14:06:30 -04:00
window . ZAMPP _API . fetch ( '/cluster/nodes/' + encodeURIComponent ( node . dbId || node . id ) , { method : 'DELETE' } )
2026-05-22 12:27:02 -04:00
. then ( ( ) => refresh ( ) )
2026-05-23 00:02:51 -04:00
. catch ( e => setAdviceModal ( { title : 'Remove failed' , lines : [ e . message ] } ) ) ;
2026-05-22 12:27:02 -04:00
} ;
2026-05-23 00:02:51 -04:00
const nodeLogsHint = ( node ) => setAdviceModal ( {
title : ` Logs for ${ node . id } ` ,
lines : [ 'Live log streaming over the websocket isn\'t wired yet. SSH to the host and tail there:' ] ,
commands : [ ` ssh ${ node . ip || node . id } 'docker compose -f /opt/dragonflight/docker-compose.yml logs -f' ` ] ,
} ) ;
2026-05-22 08:22:38 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Cluster < / h1 >
< span className = "subtitle" > { NODES . filter ( n => n . status === "online" ) . length } of { NODES . length } nodes online < / span >
< div className = "spacer" / >
< div className = "status-pip" > < span className = "dot" / > < span > Live < / span > < / div >
2026-05-22 12:27:02 -04:00
< button className = "btn ghost sm" onClick = { refresh } > < Icon name = "refresh" / > Refresh < / button >
< button className = "btn primary" onClick = { addNode } > < Icon name = "plus" / > Add node < / button >
2026-05-22 08:22:38 -04:00
< / div >
< div className = "page-body" >
< div className = "stat-row" style = { { padding : 0 , marginBottom : 16 } } >
< div className = "stat-card" >
< div className = "label" > < Icon name = "cluster" size = { 12 } / > Nodes < / div >
< div className = "value" > { NODES . length } < / div >
< / div >
< div className = "stat-card" >
2026-05-22 10:15:42 -04:00
< div className = "label" > < Icon name = "cpu" size = { 12 } / > Avg CPU < / div >
< div className = "value" > { Math . round ( NODES . reduce ( ( a , n ) => a + ( n . cpu || 0 ) , 0 ) / NODES . length ) } < span style = { { fontSize : 14 , color : "var(--text-3)" } } > % < / span > < / div >
2026-05-22 08:22:38 -04:00
< / div >
< div className = "stat-card" >
< div className = "label" > < Icon name = "gpu" size = { 12 } / > GPUs < / div >
feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 14:06:30 -04:00
< div className = "value" > { NODES . reduce ( ( a , n ) => a + n . gpuCount , 0 ) } < / div >
< / div >
< div className = "stat-card" >
< div className = "label" > < Icon name = "video" size = { 12 } / > Capture ports < / div >
< div className = "value" > { NODES . reduce ( ( a , n ) => a + n . bmdCount , 0 ) } < / div >
2026-05-22 08:22:38 -04:00
< / div >
< div className = "stat-card" >
2026-05-22 10:15:42 -04:00
< div className = "label" > < Icon name = "hdd" size = { 12 } / > Avg Memory < / div >
< div className = "value" > { Math . round ( NODES . reduce ( ( a , n ) => a + ( n . mem || 0 ) , 0 ) / NODES . length ) } < span style = { { fontSize : 14 , color : "var(--text-3)" } } > GB < / span > < / div >
2026-05-22 08:22:38 -04:00
< / div >
< / div >
< div style = { { display : "grid" , gridTemplateColumns : "1fr 340px" , gap : 16 , alignItems : "start" } } >
< div className = "cluster-canvas" >
< div style = { { display : "flex" , alignItems : "center" , justifyContent : "space-between" , padding : "12px 16px" , borderBottom : "1px solid var(--border)" } } >
< span style = { { fontWeight : 600 , fontSize : 13 } } > Topology < / span >
2026-05-23 00:02:51 -04:00
< span style = { { fontSize : 11 , color : 'var(--text-3)' } } > { NODES . length } node { NODES . length === 1 ? '' : 's' } < / span >
2026-05-22 08:22:38 -04:00
< / div >
< svg viewBox = { ` 0 0 ${ W } ${ H } ` } style = { { display : "block" , width : "100%" , height : "auto" } } >
< defs >
< radialGradient id = "nodeGlow" >
< stop offset = "0%" stopColor = "rgba(91,124,250,0.3)" / >
< stop offset = "100%" stopColor = "rgba(91,124,250,0)" / >
< / radialGradient >
< pattern id = "grid" width = "40" height = "40" patternUnits = "userSpaceOnUse" >
< path d = "M 40 0 L 0 0 0 40" fill = "none" stroke = "rgba(255,255,255,0.025)" strokeWidth = "1" / >
< / pattern >
< / defs >
< rect width = { W } height = { H } fill = "url(#grid)" / >
{ edges . map ( ( e , i ) => {
const x1 = e . from . x * W , y1 = e . from . y * H ;
const x2 = e . to . x * W , y2 = e . to . y * H ;
return (
< g key = { i } >
< line x1 = { x1 } y1 = { y1 } x2 = { x2 } y2 = { y2 }
stroke = { e . alive ? "var(--accent)" : "var(--text-4)" }
strokeWidth = "1"
strokeDasharray = { e . alive ? "0" : "4 3" }
opacity = { e . alive ? 0.5 : 0.25 }
/ >
{ e . alive && (
< circle r = "3" fill = "var(--accent)" >
< animateMotion dur = { ` ${ 2 + i * 0.4 } s ` } repeatCount = "indefinite"
path = { ` M ${ x1 } ${ y1 } L ${ x2 } ${ y2 } ` } / >
< / circle >
) }
< / g >
) ;
} ) }
{ NODES . map ( n => {
const cx = n . x * W , cy = n . y * H ;
2026-05-22 10:15:42 -04:00
const isSelected = sel && sel . id === n . id ;
2026-05-22 08:22:38 -04:00
const color = n . status === "online" ? "var(--success)" : "var(--text-4)" ;
return (
< g key = { n . id } transform = { ` translate( ${ cx } , ${ cy } ) ` }
style = { { cursor : "pointer" } }
onMouseEnter = { ( ) => setHovered ( n . id ) }
onMouseLeave = { ( ) => setHovered ( null ) }
onClick = { ( ) => setSelected ( n ) } >
{ n . status === "online" && (
< circle r = "44" fill = "url(#nodeGlow)" >
< animate attributeName = "r" values = "34;48;34" dur = "3s" repeatCount = "indefinite" / >
< / circle >
) }
< circle r = { isSelected ? 26 : 22 } fill = "var(--bg-2)" stroke = { isSelected ? "var(--accent)" : "var(--border-stronger)" } strokeWidth = { isSelected ? 2 : 1 } / >
< circle r = "6" cx = "-13" cy = "-13" fill = { color } / >
{ n . role === "primary" && < path d = "M -4 -2 L 0 2 L 4 -2 L 0 -6 Z" fill = "var(--accent)" stroke = "none" / > }
{ n . role !== "primary" && < text textAnchor = "middle" y = "3" fill = "var(--text-2)" fontSize = "10" fontFamily = "var(--font-mono)" > { n . role [ 0 ] . toUpperCase ( ) } < / text > }
< text textAnchor = "middle" y = "40" fill = { isSelected ? "var(--text-1)" : "var(--text-2)" } fontSize = "11" fontWeight = { isSelected ? 600 : 500 } > { n . id } < / text >
feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 14:06:30 -04:00
< text textAnchor = "middle" y = "54" fill = "var(--text-3)" fontSize = "10" fontFamily = "var(--font-mono)" > { n . ip } < / text >
{ ( n . gpuCount > 0 || n . bmdCount > 0 ) && (
< text textAnchor = "middle" y = "67" fill = "var(--text-4)" fontSize = "9.5" fontFamily = "var(--font-mono)" >
{ [ n . gpuCount > 0 && ` ${ n . gpuCount } × GPU` , n . bmdCount > 0 && ` ${ n . bmdCount } × BMD` ] . filter ( Boolean ) . join ( ' ' ) }
< / text >
) }
2026-05-22 08:22:38 -04:00
< / g >
) ;
} ) }
< / svg >
< / div >
2026-05-22 10:15:42 -04:00
{ sel && (
< div className = "panel" >
< div style = { { padding : "12px 16px" , borderBottom : "1px solid var(--border)" , display : "flex" , alignItems : "center" , gap : 8 } } >
< StatusDot status = { sel . status } / >
< span style = { { fontWeight : 600 , fontSize : 13 } } > { sel . id } < / span >
< span className = { ` badge ${ sel . role === "primary" ? "accent" : "neutral" } ` } > { sel . role } < / span >
< / div >
< div style = { { padding : 14 , display : "flex" , flexDirection : "column" , gap : 10 } } >
< DetailRow k = "Status" v = { < span style = { { color : sel . status === "online" ? "var(--success)" : "var(--text-3)" } } > { sel . status } < / span > } / >
< DetailRow k = "IP" v = { sel . ip } mono / >
< DetailRow k = "Version" v = { sel . version } mono / >
< DetailRow k = "Uptime" v = { sel . uptime } mono / >
< DetailRow k = "CPU" v = {
< div style = { { display : "flex" , alignItems : "center" , gap : 6 } } >
< div style = { { width : 80 , height : 4 , background : "var(--bg-3)" , borderRadius : 99 , overflow : "hidden" } } >
< div style = { { width : ` ${ sel . cpu } % ` , height : "100%" , background : "var(--accent)" } } / >
2026-05-22 08:22:38 -04:00
< / div >
2026-05-22 10:15:42 -04:00
< span className = "mono" > { sel . cpu } % < / span >
< / div >
} / >
{ sel . memTotal > 0 && (
< DetailRow k = "Memory" v = {
< div style = { { display : "flex" , alignItems : "center" , gap : 6 } } >
< div style = { { width : 80 , height : 4 , background : "var(--bg-3)" , borderRadius : 99 , overflow : "hidden" } } >
< div style = { { width : ` ${ ( sel . mem / sel . memTotal ) * 100 } % ` , height : "100%" , background : "var(--purple)" } } / >
< / div >
< span className = "mono" > { sel . mem } / { sel . memTotal } GB < / span >
2026-05-22 08:22:38 -04:00
< / div >
2026-05-22 10:15:42 -04:00
} / >
) }
feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 14:06:30 -04:00
{ /* ── GPU hardware ── */ }
< div >
< div style = { { fontSize : 11 , color : "var(--text-3)" , marginBottom : 6 , display : "flex" , alignItems : "center" , gap : 6 } } >
< Icon name = "gpu" size = { 11 } / >
GPUs { sel . gpuCount > 0 ? ` ( ${ sel . gpuCount } ) ` : '— none reported' }
2026-05-22 10:15:42 -04:00
< / div >
feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 14:06:30 -04:00
{ sel . gpus . length === 0 && (
< div style = { { fontSize : 11.5 , color : "var(--text-4)" , padding : "4px 0" } } > No GPUs detected on this node < / div >
) }
{ sel . gpus . map ( ( g , i ) => (
< div key = { i } style = { {
padding : "7px 10px" , background : "var(--bg-2)" , borderRadius : 5 , marginBottom : 4 ,
border : g . bound ? "1px solid rgba(91,250,138,0.25)" : "1px solid var(--border)" ,
display : "flex" , alignItems : "center" , gap : 8 ,
} } >
< Icon name = "gpu" size = { 12 } style = { { color : g . bound ? "var(--success)" : "var(--text-3)" , flexShrink : 0 } } / >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontSize : 12 , fontWeight : 600 , color : "var(--text-1)" } } > { g . name } < / div >
{ g . memMb && (
< div style = { { fontSize : 11 , color : "var(--text-3)" , fontFamily : "var(--font-mono)" , marginTop : 1 } } >
{ g . memMb >= 1024 ? ( g . memMb / 1024 ) . toFixed ( 1 ) + ' GB' : g . memMb + ' MB' } VRAM
< / div >
) }
{ g . device && < div style = { { fontSize : 10.5 , color : "var(--text-4)" , fontFamily : "var(--font-mono)" } } > { g . device } < / div > }
2026-05-22 10:15:42 -04:00
< / div >
feat(cluster): full hardware breakdown per node
_normalizeNode:
- Maps cpu_usage, mem_used_mb/mem_total_mb from actual API fields
- Reads capabilities.gpus: name, memory_mb, device, bound status
(bound = nvidia-smi confirmed driver, detected by name+memory_mb)
- Reads capabilities.blackmagic + blackmagic_model: model, port count,
device paths
Node detail panel:
- GPUs: name, VRAM, device path, BOUND/UNBOUND badge (green if driver active)
- Capture cards: model name, port count badge, per-port device name
with online/offline color coding
Stat row: adds Capture ports total count card
Topology SVG: shows GPU count and BMD port count under each node label
Fix: removeNode uses node.dbId (UUID) not node.id (hostname)
2026-05-26 14:06:30 -04:00
< span style = { {
fontSize : 10 , fontWeight : 600 , padding : "2px 6px" , borderRadius : 3 ,
background : g . bound ? "rgba(91,250,138,0.15)" : "rgba(255,255,255,0.05)" ,
color : g . bound ? "var(--success)" : "var(--text-3)" ,
} } >
{ g . bound ? "BOUND" : "UNBOUND" }
< / span >
< / div >
) ) }
< / div >
{ /* ── Capture cards ── */ }
2026-05-26 18:02:38 -04:00
< BmdCardPanel sel = { sel } portSignals = { portSignals } / >
2026-05-22 10:15:42 -04:00
< div style = { { display : "flex" , gap : 6 , marginTop : 6 } } >
2026-05-22 12:27:02 -04:00
< button className = "btn ghost sm" onClick = { ( ) => nodeLogsHint ( sel ) } > Logs < / button >
< button className = "btn ghost sm" onClick = { ( ) => drainNode ( sel ) } > Drain < / button >
{ sel . role !== "primary" && < button className = "btn danger sm" onClick = { ( ) => removeNode ( sel ) } > Remove < / button > }
2026-05-22 08:22:38 -04:00
< / div >
< / div >
< / div >
2026-05-22 10:15:42 -04:00
) }
2026-05-22 08:22:38 -04:00
< / div >
< / div >
2026-05-23 00:02:51 -04:00
{ adviceModal && (
< div className = "modal-backdrop" onClick = { ( ) => setAdviceModal ( null ) } >
< div className = "modal" style = { { width : 560 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "modal-head" >
< div style = { { fontSize : 15 , fontWeight : 600 } } > { adviceModal . title } < / 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 = { ( ) => setAdviceModal ( null ) } > < Icon name = "x" / > < / button >
2026-05-23 00:02:51 -04:00
< / div >
< div className = "modal-body" >
{ ( adviceModal . lines || [ ] ) . map ( ( l , i ) => (
< div key = { i } style = { { fontSize : 12.5 , color : 'var(--text-2)' , marginBottom : 6 , lineHeight : 1.55 } } > { l } < / div >
) ) }
{ ( adviceModal . commands || [ ] ) . map ( ( c , i ) => (
< code key = { i } className = "mono" style = { { display : 'block' , background : 'var(--bg-2)' , padding : 10 , borderRadius : 5 , fontSize : 11.5 , marginTop : 8 , overflowX : 'auto' } } > { c } < / code >
) ) }
< / div >
< div className = "modal-foot" >
{ adviceModal . commands && adviceModal . commands . length > 0 && (
< button className = "btn ghost sm" onClick = { ( ) => {
if ( navigator . clipboard ) navigator . clipboard . writeText ( adviceModal . commands . join ( '\n' ) ) . catch ( ( ) => { } ) ;
} } > Copy commands < / button >
) }
< button className = "btn primary sm" onClick = { ( ) => setAdviceModal ( null ) } > Got it < / button >
< / div >
< / div >
< / div >
) }
2026-05-22 08:22:38 -04:00
< / div >
) ;
}
function DetailRow ( { k , v , mono } ) {
return (
< div style = { { display : "grid" , gridTemplateColumns : "90px 1fr" , alignItems : "center" , fontSize : 12 } } >
< span style = { { color : "var(--text-3)" } } > { k } < / span >
< span className = { mono ? "mono" : "" } style = { { fontSize : mono ? 11.5 : 12 } } > { v } < / span >
< / div >
) ;
}
2026-05-27 15:20:57 -04:00
function AccountSection ( ) {
const [ current , setCurrent ] = React . useState ( '' ) ;
const [ next , setNext ] = React . useState ( '' ) ;
const [ confirm , setConfirm ] = React . useState ( '' ) ;
const [ msg , setMsg ] = React . useState ( null ) ; // { kind: 'ok'|'err', text }
const [ busy , setBusy ] = React . useState ( false ) ;
const submit = async ( ) => {
setMsg ( null ) ;
if ( next !== confirm ) { setMsg ( { kind : 'err' , text : 'Passwords do not match' } ) ; return ; }
if ( next . length < 12 ) { setMsg ( { kind : 'err' , text : 'New password must be at least 12 characters' } ) ; return ; }
setBusy ( true ) ;
try {
const r = await fetch ( '/api/v1/auth/password' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' , 'X-Requested-With' : 'dragonflight-ui' } ,
body : JSON . stringify ( { current _password : current , new _password : next } ) ,
} ) ;
if ( r . status === 204 ) {
setMsg ( { kind : 'ok' , text : 'Password updated' } ) ;
setCurrent ( '' ) ; setNext ( '' ) ; setConfirm ( '' ) ;
} else {
const body = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
setMsg ( { kind : 'err' , text : body . error || 'Failed (' + r . status + ')' } ) ;
}
} finally { setBusy ( false ) ; }
} ;
return (
< section className = "panel" style = { { padding : 16 , marginBottom : 16 } } >
< h3 style = { { fontSize : 10.5 , fontWeight : 600 , color : 'var(--text-3)' , letterSpacing : '0.08em' , textTransform : 'uppercase' , marginBottom : 12 } } > Account < / h3 >
< div style = { { display : 'grid' , gridTemplateColumns : '160px 1fr' , gap : 10 , alignItems : 'center' , maxWidth : 480 } } >
< label > Current password < / label >
< input type = "password" autoComplete = "current-password" className = "field-input" value = { current } onChange = { e => setCurrent ( e . target . value ) } / >
< label > New password < / label >
< input type = "password" autoComplete = "new-password" className = "field-input" value = { next } onChange = { e => setNext ( e . target . value ) } / >
< label > Confirm new password < / label >
< input type = "password" autoComplete = "new-password" className = "field-input" value = { confirm } onChange = { e => setConfirm ( e . target . value ) } / >
< / div >
{ msg && (
< div style = { { marginTop : 10 , fontSize : 11.5 , color : msg . kind === 'ok' ? 'var(--success)' : 'var(--danger)' } } > { msg . text } < / div >
) }
< button className = "btn primary sm" style = { { marginTop : 12 } } disabled = { busy || ! current || ! next || ! confirm } onClick = { submit } >
Change password
< / button >
< / section >
) ;
}
function ApiTokensSection ( ) {
const [ tokens , setTokens ] = React . useState ( [ ] ) ;
const [ name , setName ] = React . useState ( '' ) ;
const [ justCreated , setJustCreated ] = React . useState ( null ) ; // { token, prefix, name }
const [ busy , setBusy ] = React . useState ( false ) ;
const load = React . useCallback ( async ( ) => {
const r = await fetch ( '/api/v1/auth/tokens' , { credentials : 'include' } ) ;
if ( r . status === 200 ) setTokens ( await r . json ( ) ) ;
} , [ ] ) ;
React . useEffect ( ( ) => { load ( ) ; } , [ load ] ) ;
const create = async ( ) => {
if ( ! name . trim ( ) ) return ;
setBusy ( true ) ;
try {
const r = await fetch ( '/api/v1/auth/tokens' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' , 'X-Requested-With' : 'dragonflight-ui' } ,
body : JSON . stringify ( { name : name . trim ( ) } ) ,
} ) ;
if ( r . status === 201 ) {
const created = await r . json ( ) ;
setJustCreated ( created ) ;
setName ( '' ) ;
await load ( ) ;
}
} finally { setBusy ( false ) ; }
} ;
const revoke = async ( id ) => {
await fetch ( '/api/v1/auth/tokens/' + id , {
method : 'DELETE' ,
credentials : 'include' ,
headers : { 'X-Requested-With' : 'dragonflight-ui' } ,
} ) ;
await load ( ) ;
} ;
return (
< section className = "panel" style = { { padding : 16 , marginBottom : 16 } } >
< h3 style = { { fontSize : 10.5 , fontWeight : 600 , color : 'var(--text-3)' , letterSpacing : '0.08em' , textTransform : 'uppercase' , marginBottom : 12 } } > API Tokens < / h3 >
{ justCreated && (
< div style = { { marginBottom : 12 , padding : 10 , border : '1px solid var(--accent)' , background : 'var(--accent-soft)' , borderRadius : 6 } } >
< div style = { { fontSize : 11 , fontWeight : 600 , color : 'var(--accent-text)' , marginBottom : 6 } } >
Save this token now — it will not be shown again
< / div >
< div style = { { fontFamily : 'var(--font-mono)' , fontSize : 12 , color : 'var(--text-1)' , wordBreak : 'break-all' , marginBottom : 6 } } > { justCreated . token } < / div >
< button className = "btn sm" onClick = { ( ) => navigator . clipboard . writeText ( justCreated . token ) } > Copy < / button >
< button className = "btn sm" style = { { marginLeft : 8 } } onClick = { ( ) => setJustCreated ( null ) } > Dismiss < / button >
< / div >
) }
< div style = { { display : 'flex' , gap : 8 , marginBottom : 12 } } >
< input className = "field-input" placeholder = "Token name (e.g. Premiere panel)" value = { name } onChange = { e => setName ( e . target . value ) } style = { { flex : 1 } } / >
< button className = "btn primary sm" disabled = { busy || ! name . trim ( ) } onClick = { create } > New token < / button >
< / div >
< div className = "token-list" >
{ tokens . length === 0 && < div style = { { fontSize : 11.5 , color : 'var(--text-3)' } } > No tokens yet . < / div > }
{ tokens . map ( t => (
< div key = { t . id } className = "token-row" style = { { display : 'grid' , gridTemplateColumns : '1fr 120px 140px 80px' , gap : 10 , alignItems : 'center' , padding : '8px 0' , borderBottom : '1px solid var(--border)' } } >
< div > { t . name } < / div >
< div style = { { fontFamily : 'var(--font-mono)' , fontSize : 11 , color : 'var(--text-2)' } } > { t . prefix } … < / div >
< div style = { { fontSize : 11 , color : 'var(--text-3)' } } > { t . last _used _at ? new Date ( t . last _used _at ) . toLocaleString ( ) : 'never used' } < / div >
< button className = "btn sm" onClick = { ( ) => revoke ( t . id ) } > Revoke < / button >
< / div >
) ) }
< / div >
< / section >
) ;
}
2026-05-22 08:22:38 -04:00
function Settings ( ) {
2026-05-27 15:20:57 -04:00
const [ section , setSection ] = React . useState ( 'account' ) ;
2026-05-22 12:08:10 -04:00
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 SECTIONS = [
2026-05-27 15:20:57 -04:00
{ id : 'account' , label : 'Account' , icon : 'user' } ,
2026-05-26 18:45:50 -04:00
{ id : 'storage' , label : 'Storage' , icon : 'hdd' } ,
{ id : 'proxy' , label : 'Proxy encoding' , icon : 'gpu' } ,
{ id : 'sdk' , label : 'Capture SDKs' , icon : 'video' } ,
{ id : 'sdi' , label : 'SDI capture' , icon : 'video' } ,
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
] ;
2026-05-22 12:08:10 -04:00
2026-05-22 08:22:38 -04:00
return (
< div className = "page" >
< div className = "page-header" >
< h1 > Settings < / h1 >
< span className = "subtitle" > System configuration · changes apply without restart < / span >
< / div >
< div className = "page-body" >
2026-05-22 12:08:10 -04:00
< div style = { { display : 'grid' , gridTemplateColumns : '200px 1fr' , gap : 24 , alignItems : 'start' } } >
2026-05-22 08:22:38 -04:00
< nav className = "settings-nav" >
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
{ SECTIONS . map ( s => (
< a key = { s . id }
className = { ` settings-nav-item ${ section === s . id ? 'active' : '' } ` }
onClick = { ( ) => setSection ( s . id ) }
style = { { cursor : 'pointer' } } >
2026-05-22 08:22:38 -04:00
< Icon name = { s . icon } size = { 14 } / > { s . label }
< / a >
) ) }
< / nav >
2026-05-22 12:08:10 -04:00
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 16 } } >
2026-05-27 15:20:57 -04:00
{ section === 'account' && (
< >
< AccountSection / >
< ApiTokensSection / >
< / >
) }
2026-05-26 18:45:50 -04:00
{ section === 'storage' && < StorageSection / > }
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
{ section === 'proxy' && < GpuSettingsCard / > }
{ section === 'sdk' && < SdkSettingsCard / > }
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
{ section === 'sdi' && < SdiSettingsCard / > }
2026-05-22 08:22:38 -04:00
< / div >
< / div >
< / div >
< / div >
) ;
}
2026-05-26 18:45:50 -04:00
// ────────────────────────────────────────────────────────────────────────────
// Storage — unified view: live mount/bucket health on top, then the two
// existing editors (S3 bucket + growing-files SMB landing zone) stacked.
// ────────────────────────────────────────────────────────────────────────────
function StorageSection ( ) {
return (
< >
< MountHealthStrip / >
< S3SettingsCard / >
< GrowingSettingsCard / >
< / >
) ;
}
function formatBytes ( n ) {
if ( n == null || isNaN ( n ) ) return '—' ;
const units = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' , 'PB' ] ;
let v = n , i = 0 ;
while ( v >= 1024 && i < units . length - 1 ) { v /= 1024 ; i ++ ; }
return ` ${ v . toFixed ( v >= 100 || i === 0 ? 0 : 1 ) } ${ units [ i ] } ` ;
}
function HealthPill ( { ok , label , detail } ) {
const cls = ok ? 'badge success' : 'badge warning' ;
return (
< span className = { cls } title = { detail || '' } style = { { display : 'inline-flex' , alignItems : 'center' , gap : 6 } } >
< span style = { { width : 6 , height : 6 , borderRadius : 999 , background : 'currentColor' , display : 'inline-block' } } / >
{ label }
< / span >
) ;
}
function MountHealthStrip ( ) {
const [ data , setData ] = React . useState ( null ) ;
const [ error , setError ] = React . useState ( null ) ;
const [ refreshing , setRefresh ] = React . useState ( false ) ;
const load = React . useCallback ( ( ) => {
setRefresh ( true ) ;
window . ZAMPP _API . fetch ( '/storage/overview' )
. then ( d => { setData ( d ) ; setError ( null ) ; } )
. catch ( e => setError ( e . message || String ( e ) ) )
. finally ( ( ) => setRefresh ( false ) ) ;
} , [ ] ) ;
React . useEffect ( ( ) => {
load ( ) ;
// Light auto-refresh so free-space + reachability stay current while the
// operator is on the page. 15s is plenty — these are diagnostic, not real-time.
const t = setInterval ( load , 15 _000 ) ;
return ( ) => clearInterval ( t ) ;
} , [ load ] ) ;
if ( error ) {
return (
< SettingsCard icon = "hdd" title = "Mount health" sub = "Live diagnostics for the storage subsystem"
tag = { < span className = "badge warning" > unavailable < / span > } >
< SettingsMsg msg = { { ok : false , text : 'Could not load /storage/overview: ' + error } } / >
< / SettingsCard >
) ;
}
if ( ! data ) {
return (
< SettingsCard icon = "hdd" title = "Mount health" sub = "Live diagnostics for the storage subsystem" >
< div style = { { color : 'var(--text-3)' , fontSize : 12.5 } } > Probing … < / div >
< / SettingsCard >
) ;
}
const g = data . growing ;
const s = data . s3 ;
const growingHealthy = g . enabled ? ( g . exists && g . writable ) : true ;
return (
< SettingsCard icon = "hdd" title = "Mount health" sub = "Live diagnostics for the storage subsystem"
tag = {
< button className = "btn ghost sm" onClick = { load } disabled = { refreshing } title = "Re-probe now"
style = { { padding : '2px 8px' } } >
{ refreshing ? '…' : 'Refresh' }
< / button >
} >
{ /* ── Growing-files row ─────────────────────────────────────────────── */ }
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 6 } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 , flexWrap : 'wrap' } } >
< strong style = { { fontSize : 12.5 } } > Growing files < / strong >
{ g . enabled
? < HealthPill ok = { growingHealthy } label = { growingHealthy ? 'mounted' : 'unreachable' } detail = { g . error || '' } / >
: < span className = "badge neutral" > disabled < / span > }
{ g . enabled && g . exists && (
< HealthPill ok = { g . writable } label = { g . writable ? 'writable' : 'read-only' } / >
) }
{ g . free _bytes != null && (
< span className = "badge neutral" title = { g . total _bytes ? ` of ${ formatBytes ( g . total _bytes ) } total ` : '' } >
{ formatBytes ( g . free _bytes ) } free
< / span >
) }
< / div >
< div style = { { fontSize : 11.5 , color : 'var(--text-3)' , display : 'grid' , gridTemplateColumns : 'auto 1fr' , columnGap : 10 , rowGap : 2 } } >
< span > Container < / span > < span className = "mono" > { g . container _path || '—' } < / span >
< span > Host < / span > < span className = "mono" > { g . host _path || '—' } < / span >
< span > SMB < / span > < span className = "mono" > { g . smb _url || '—' } < / span >
< span > Promote idle < / span > < span className = "mono" > { g . promote _after _seconds } s < / span >
{ g . error && < > < span > Error < / span > < span style = { { color : 'var(--danger)' } } > { g . error } < / span > < / > }
< / div >
< / div >
< div style = { { height : 1 , background : 'var(--border-1, rgba(255,255,255,0.06))' , margin : '10px 0' } } / >
{ /* ── S3 bucket row ─────────────────────────────────────────────────── */ }
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 6 } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 , flexWrap : 'wrap' } } >
< strong style = { { fontSize : 12.5 } } > S3 bucket < / strong >
< HealthPill ok = { s . reachable } label = { s . reachable ? 'reachable' : 'unreachable' } detail = { s . error || '' } / >
{ s . head _latency _ms != null && (
< span className = "badge neutral" > { s . head _latency _ms } ms < / span >
) }
{ s . probe _method && < span className = "badge neutral" > { s . probe _method } < / span > }
< / div >
< div style = { { fontSize : 11.5 , color : 'var(--text-3)' , display : 'grid' , gridTemplateColumns : 'auto 1fr' , columnGap : 10 , rowGap : 2 } } >
< span > Endpoint < / span > < span className = "mono" > { s . endpoint || '(AWS default)' } < / span >
< span > Bucket < / span > < span className = "mono" > { s . bucket || '—' } < / span >
< span > Region < / span > < span className = "mono" > { s . region || '—' } < / span >
{ s . error && < > < span > Error < / span > < span style = { { color : 'var(--danger)' } } > { s . error } < / span > < / > }
< / div >
< / div >
< / SettingsCard >
) ;
}
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
function S3SettingsCard ( ) {
const [ s3 , setS3 ] = React . useState ( { s3 _endpoint : '' , s3 _bucket : '' , s3 _access _key : '' , s3 _secret _key : '' , s3 _region : 'us-east-1' } ) ;
const [ loading , setLoading ] = React . useState ( true ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ testing , setTesting ] = React . useState ( false ) ;
const [ msg , setMsg ] = React . useState ( null ) ;
const [ secretExists , setSecretExists ] = React . useState ( false ) ;
React . useEffect ( ( ) => {
window . ZAMPP _API . fetch ( '/settings/s3' )
. then ( data => {
2026-05-23 13:19:48 -04:00
// Diagnostic: previous reports of "endpoint always blank" were
// hard to chase without seeing the raw payload. Log it once on
// load so the next user can verify quickly.
try { console . debug ( '[settings] /settings/s3 →' , data ) ; } catch ( _ ) { }
setS3 ( {
s3 _endpoint : data . s3 _endpoint || '' ,
s3 _bucket : data . s3 _bucket || '' ,
s3 _access _key : data . s3 _access _key || '' ,
s3 _secret _key : '' ,
s3 _region : data . s3 _region || 'us-east-1' ,
} ) ;
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
setSecretExists ( ! ! data . s3 _secret _key _exists ) ;
setLoading ( false ) ;
} )
2026-05-23 13:19:48 -04:00
. catch ( err => {
console . error ( '[settings] /settings/s3 failed:' , err ) ;
setMsg ( { ok : false , text : 'Could not load S3 settings: ' + ( err . message || err ) } ) ;
setLoading ( false ) ;
} ) ;
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 save = ( ) => {
setSaving ( true ) ; setMsg ( null ) ;
window . ZAMPP _API . fetch ( '/settings/s3' , { method : 'PUT' , body : JSON . stringify ( s3 ) } )
. then ( ( ) => { setSaving ( false ) ; setMsg ( { ok : true , text : 'Saved and applied.' } ) ; if ( s3 . s3 _secret _key ) setSecretExists ( true ) ; } )
. catch ( e => { setSaving ( false ) ; setMsg ( { ok : false , text : e . message } ) ; } ) ;
} ;
const test = ( ) => {
setTesting ( true ) ; setMsg ( null ) ;
window . ZAMPP _API . fetch ( '/settings/s3/test' , { method : 'POST' , body : JSON . stringify ( s3 ) } )
. then ( r => { setTesting ( false ) ; setMsg ( { ok : r . ok !== false , text : r . message || 'Connection OK' } ) ; } )
. catch ( e => { setTesting ( false ) ; setMsg ( { ok : false , text : e . message } ) ; } ) ;
} ;
return (
< SettingsCard icon = "hdd" title = "S3 / Object Storage" sub = "S3-compatible bucket for media asset storage"
tag = { secretExists ? < span className = "badge success" > connected < / span > : < span className = "badge warning" > not configured < / 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
{ loading ? < div style = { { color : 'var(--text-3)' , fontSize : 12.5 } } > Loading … < / div > : (
< form onSubmit = { e => { e . preventDefault ( ) ; save ( ) ; } } autoComplete = "off" >
< SField label = "Endpoint URL" >
< input className = "field-input mono" type = "url" required value = { s3 . s3 _endpoint } onChange = { e => setS3 ( p => ( { ... p , s3 _endpoint : e . target . value } ) ) } placeholder = "https://s3.example.com" / >
< / SField >
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 } } >
< SField label = "Region" > < input className = "field-input mono" required value = { s3 . s3 _region } onChange = { e => setS3 ( p => ( { ... p , s3 _region : e . target . value } ) ) } placeholder = "us-east-1" / > < / SField >
< SField label = "Bucket" > < input className = "field-input mono" required value = { s3 . s3 _bucket } onChange = { e => setS3 ( p => ( { ... p , s3 _bucket : e . target . value } ) ) } placeholder = "my-bucket" / > < / SField >
< / div >
< SField label = "Access key ID" > < input className = "field-input mono" required value = { s3 . s3 _access _key } onChange = { e => setS3 ( p => ( { ... p , s3 _access _key : e . target . value } ) ) } placeholder = "Access key ID" autoComplete = "off" / > < / SField >
< SField label = "Secret access key" > < input className = "field-input mono" type = "password" value = { s3 . s3 _secret _key } onChange = { e => setS3 ( p => ( { ... p , s3 _secret _key : e . target . value } ) ) } placeholder = { secretExists ? '(saved — type to replace)' : 'Secret key' } autoComplete = "new-password" / > < / SField >
< SettingsMsg msg = { msg } / >
< div style = { { display : 'flex' , gap : 6 , marginTop : 8 } } >
< button type = "submit" className = "btn primary sm" disabled = { saving } > { saving ? 'Saving…' : 'Save & apply' } < / button >
< button type = "button" className = "btn ghost sm" onClick = { test } disabled = { testing } > { testing ? 'Testing…' : 'Test connection' } < / button >
< / div >
< / form >
) }
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
< / SettingsCard >
) ;
}
function GpuSettingsCard ( ) {
const [ cfg , setCfg ] = React . useState ( null ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ msg , setMsg ] = React . useState ( null ) ;
React . useEffect ( ( ) => {
window . ZAMPP _API . fetch ( '/settings/transcoding' ) . then ( setCfg ) . catch ( ( ) => setCfg ( { } ) ) ;
} , [ ] ) ;
const save = ( ) => {
setSaving ( true ) ; setMsg ( null ) ;
window . ZAMPP _API . fetch ( '/settings/transcoding' , { method : 'PUT' , body : JSON . stringify ( cfg ) } )
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
. then ( ( ) => { setSaving ( false ) ; setMsg ( { ok : true , text : 'Saved — new settings apply to the next proxy job.' } ) ; } )
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
. catch ( e => { setSaving ( false ) ; setMsg ( { ok : false , text : e . message } ) ; } ) ;
} ;
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
if ( ! cfg ) return < SettingsCard icon = "gpu" title = "Proxy encoding" sub = "Global proxy encoder applied to every ingested file" > < div style = { { color : 'var(--text-3)' , fontSize : 12.5 } } > Loading … < / div > < / SettingsCard > ;
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 set = ( k , v ) => setCfg ( c => ( { ... c , [ k ] : v } ) ) ;
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
const gpuEnabled = cfg . gpu _transcode _enabled === 'true' || cfg . gpu _transcode _enabled === true ;
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
return (
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< SettingsCard icon = "gpu" title = "Proxy encoding" sub = "Global proxy encoder applied to every ingested file"
tag = { gpuEnabled ? < span className = "badge success" > GPU mode < / span > : < span className = "badge neutral" > CPU mode < / 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
< form onSubmit = { e => { e . preventDefault ( ) ; save ( ) ; } } autoComplete = "off" >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< div style = { { fontSize : 12 , color : 'var(--text-3)' , lineHeight : 1.55 , marginBottom : 4 } } >
These settings drive the proxy worker for < strong style = { { color : 'var(--text-2)' } } > every < / strong > ingested asset ( SDI , SRT , RTMP , upload ) . GPU mode uses NVENC / VAAPI when hardware is available ; CPU mode uses libx264 .
< / div >
< SField label = "Hardware acceleration" >
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
< label style = { { display : 'flex' , alignItems : 'center' , gap : 8 , fontSize : 12.5 } } >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< input type = "checkbox" checked = { gpuEnabled } onChange = { e => set ( 'gpu_transcode_enabled' , String ( e . target . checked ) ) } / >
< span style = { { color : 'var(--text-2)' } } > Use GPU encoders ( NVENC / VAAPI ) when available — falls back to CPU on missing hardware < / span >
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
< / label >
< / SField >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
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
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 } } >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< SField label = { gpuEnabled ? 'GPU codec' : 'CPU codec' } >
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
< select className = "field-input" value = { cfg . gpu _codec || 'h264_nvenc' } onChange = { e => set ( 'gpu_codec' , e . target . value ) } style = { { appearance : 'auto' } } >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
{ gpuEnabled ? ( < >
< option value = "h264_nvenc" > h264 _nvenc ( NVIDIA ) < / option >
< option value = "hevc_nvenc" > hevc _nvenc ( NVIDIA HEVC ) < / option >
< option value = "h264_vaapi" > h264 _vaapi ( Intel / AMD ) < / option >
< option value = "hevc_vaapi" > hevc _vaapi ( Intel / AMD HEVC ) < / option >
< / > ) : ( < >
< option value = "libx264" > libx264 ( H .264 , recommended ) < / option >
< option value = "libx265" > libx265 ( HEVC , slower ) < / option >
< / > ) }
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
< / select >
< / SField >
< SField label = "Preset" >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< select className = "field-input" value = { cfg . gpu _preset || ( gpuEnabled ? 'p4' : 'fast' ) } onChange = { e => set ( 'gpu_preset' , e . target . value ) } style = { { appearance : 'auto' } } >
{ gpuEnabled
? [ 'p1' , 'p2' , 'p3' , 'p4' , 'p5' , 'p6' , 'p7' ] . map ( p => < option key = { p } value = { p } > { p } < / option > )
: [ 'ultrafast' , 'superfast' , 'veryfast' , 'faster' , 'fast' , 'medium' , 'slow' , 'slower' ] . map ( p => < option key = { p } value = { p } > { p } < / option > ) }
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
< / select >
< / SField >
< / div >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
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
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 } } >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< SField label = "Target bitrate (Mbps)" >
< input className = "field-input mono" type = "number" value = { cfg . gpu _bitrate _mbps || '' } onChange = { e => set ( 'gpu_bitrate_mbps' , e . target . value ) } placeholder = "10" / >
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
< / SField >
< SField label = "Rate control" >
< select className = "field-input" value = { cfg . gpu _rc _mode || 'cbr' } onChange = { e => set ( 'gpu_rc_mode' , e . target . value ) } style = { { appearance : 'auto' } } >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< option value = "cbr" > CBR — constant bitrate < / option >
< option value = "vbr" > VBR — variable bitrate < / option >
< option value = "cqp" > CQP / CRF — constant quality < / option >
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
< / select >
< / SField >
< / div >
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : 12 } } >
< SField label = "Audio codec" >
< select className = "field-input" value = { cfg . gpu _audio _codec || 'aac' } onChange = { e => set ( 'gpu_audio_codec' , e . target . value ) } style = { { appearance : 'auto' } } >
< option value = "aac" > aac < / option >
< option value = "opus" > opus < / option >
< option value = "mp3" > mp3 < / option >
< / select >
< / SField >
< SField label = "Audio bitrate (kbps)" >
< input className = "field-input mono" type = "number" value = { cfg . gpu _audio _bitrate _kbps || '' } onChange = { e => set ( 'gpu_audio_bitrate_kbps' , e . target . value ) } placeholder = "192" / >
< / SField >
< / 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
< SettingsMsg msg = { msg } / >
< div style = { { display : 'flex' , gap : 6 , marginTop : 8 } } >
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 type = "submit" className = "btn primary sm" disabled = { saving } > { saving ? 'Saving…' : 'Save' } < / button >
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
< / 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
< / form >
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
< / SettingsCard >
) ;
}
function GrowingSettingsCard ( ) {
const [ cfg , setCfg ] = React . useState ( null ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ msg , setMsg ] = React . useState ( null ) ;
React . useEffect ( ( ) => {
window . ZAMPP _API . fetch ( '/settings/growing' ) . then ( setCfg ) . catch ( ( ) => setCfg ( {
growing _enabled : 'false' , growing _path : '/growing' , growing _smb _url : '' , growing _promote _after _seconds : '8' ,
} ) ) ;
} , [ ] ) ;
const save = ( ) => {
setSaving ( true ) ; setMsg ( null ) ;
window . ZAMPP _API . fetch ( '/settings/growing' , { method : 'PUT' , body : JSON . stringify ( cfg ) } )
. then ( ( ) => { setSaving ( false ) ; setMsg ( { ok : true , text : 'Saved.' } ) ; } )
. catch ( e => { setSaving ( false ) ; setMsg ( { ok : false , text : e . message } ) ; } ) ;
} ;
if ( ! cfg ) return < SettingsCard icon = "hdd" title = "Growing files (SMB)" sub = "Loading…" > < div style = { { color : 'var(--text-3)' } } > … < / div > < / SettingsCard > ;
const set = ( k , v ) => setCfg ( c => ( { ... c , [ k ] : v } ) ) ;
const enabled = cfg . growing _enabled === 'true' || cfg . growing _enabled === true ;
return (
< SettingsCard icon = "hdd" title = "Growing files (SMB)" sub = "High-speed local landing zone; promote to S3 on stop"
tag = { enabled ? < span className = "badge success" > enabled < / span > : < span className = "badge neutral" > disabled < / 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
< form onSubmit = { e => { e . preventDefault ( ) ; save ( ) ; } } autoComplete = "off" >
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
< SField label = "Enable growing-file capture" >
< label style = { { display : 'flex' , alignItems : 'center' , gap : 8 , fontSize : 12.5 } } >
< input type = "checkbox" checked = { enabled } onChange = { e => set ( 'growing_enabled' , String ( e . target . checked ) ) } / >
< span style = { { color : 'var(--text-2)' } } > Capture writes to the local SMB share first ; Premier can edit while it ' s still growing . < / span >
< / label >
< / SField >
< SField label = "Container mount path" >
< input className = "field-input mono" value = { cfg . growing _path || '' } onChange = { e => set ( 'growing_path' , e . target . value ) } placeholder = "/growing" / >
< / SField >
< SField label = "SMB share URL (for editors)" >
< input className = "field-input mono" value = { cfg . growing _smb _url || '' } onChange = { e => set ( 'growing_smb_url' , e . target . value ) } placeholder = "smb://10.0.0.25/mam-growing" / >
< / SField >
< SField label = "Promote-to-S3 idle threshold (seconds)" >
< input className = "field-input mono" type = "number" value = { cfg . growing _promote _after _seconds || '' } onChange = { e => set ( 'growing_promote_after_seconds' , e . target . value ) } placeholder = "8" / >
< / SField >
< SettingsMsg msg = { msg } / >
< div style = { { display : 'flex' , gap : 6 , marginTop : 8 } } >
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 type = "submit" className = "btn primary sm" disabled = { saving } > { saving ? 'Saving…' : 'Save' } < / button >
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
< / 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
< / form >
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
< / SettingsCard >
) ;
}
function SdiSettingsCard ( ) {
return (
< SettingsCard icon = "video" title = "SDI capture" sub = "DeckLink device routing and defaults"
tag = { < span className = "badge neutral" > per - recorder < / span > } >
< div style = { { color : 'var(--text-3)' , fontSize : 12.5 , lineHeight : 1.6 } } >
SDI settings are configured per - recorder . Use { ' ' }
< strong style = { { color : 'var(--text-2)' } } > Ingest → Recorders → New recorder < / strong > { ' ' }
to pick the DeckLink port , codec , and audio routing .
< / div >
< div style = { { marginTop : 12 } } >
< a className = "btn ghost sm" href = "#" onClick = { e => { e . preventDefault ( ) ; window . dispatchEvent ( new CustomEvent ( 'dragonflight-navigate' , { detail : 'capture' } ) ) ; } } >
< Icon name = "video" / > Open Capture dashboard
< / a >
< / div >
< / SettingsCard >
) ;
}
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
// ────────────────────────────────────────────────────────────────────────────
// Capture SDK deployment — Blackmagic / AJA / Deltacast
// ────────────────────────────────────────────────────────────────────────────
const SDK _VENDORS = [
{
id : 'blackmagic' ,
name : 'Blackmagic DeckLink' ,
sub : 'DeckLink SDK 16.x — required for SDI capture via DeckLink cards' ,
expect : 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so' ,
docs : 'https://www.blackmagicdesign.com/developer/product/capture' ,
buildHint : 'docker compose build --no-cache capture' ,
status : 'wired' ,
} ,
{
id : 'aja' ,
name : 'AJA NTV2' ,
sub : 'NTV2 SDK — for Kona / Io / U-Tap / T-Tap cards' ,
expect : 'libajantv2.so, ntv2card.h, ntv2enums.h' ,
docs : 'https://sdksupport.aja.com/' ,
buildHint : 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build' ,
status : 'staging-only' ,
} ,
{
id : 'deltacast' ,
name : 'Deltacast VideoMaster' ,
sub : 'VideoMasterHD SDK — for FLEX / DELTA-h4k2 / etc.' ,
expect : 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so' ,
docs : 'https://www.deltacast.tv/products/sdk' ,
buildHint : 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build' ,
status : 'staging-only' ,
} ,
] ;
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
// Premiere panel releases — single source of truth lives on `window.PREMIERE_RELEASES`
// (see data.jsx). Local alias for readability.
const PREMIERE _RELEASES = window . PREMIERE _RELEASES ;
2026-05-26 10:34:28 -04:00
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
function SdkSettingsCard ( ) {
const [ statuses , setStatuses ] = React . useState ( null ) ;
const [ msg , setMsg ] = React . useState ( null ) ;
const load = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/sdk' ) . then ( setStatuses ) . catch ( ( ) => setStatuses ( { } ) ) ;
} , [ ] ) ;
React . useEffect ( ( ) => { load ( ) ; } , [ load ] ) ;
return (
< SettingsCard icon = "video" title = "Capture SDKs" sub = "Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
tag = { < span className = "badge neutral" > { SDK _VENDORS . length } vendors < / span > } >
2026-05-26 10:34:28 -04:00
{ /* ── Premiere Panel download section ── */ }
< div style = { { marginBottom : 18 } } >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text-2)' , marginBottom : 8 , textTransform : 'uppercase' , letterSpacing : '0.06em' } } >
Premiere Pro Panel
< / div >
< div style = { { fontSize : 12 , color : 'var(--text-3)' , lineHeight : 1.55 , marginBottom : 10 } } >
The Dragonflight CEP panel enables growing - file editing , batch trim , and one - click hi - res relink directly inside Premiere Pro .
Install the < strong style = { { color : 'var(--text-2)' } } > . zxp < / strong > via < a href = "https://zxpsign.com/zxp-installer" target = "_blank" rel = "noreferrer" style = { { color : 'var(--accent)' } } > ZXP Installer < / a > ( Mac / Win ) ,
or run the < strong style = { { color : 'var(--text-2)' } } > Windows Setup < / strong > which bundles the installer automatically .
< / div >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 8 } } >
{ PREMIERE _RELEASES . map ( r => (
< div key = { r . version } style = { { border : '1px solid var(--border)' , borderRadius : 6 , padding : '10px 12px' , background : 'var(--bg-2)' , display : 'flex' , alignItems : 'center' , gap : 10 } } >
< div style = { { flex : 1 } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
< strong style = { { fontSize : 13 } } > v { r . version } < / strong >
{ r . latest && < span className = "badge success" > latest < / span > }
< / div >
< div style = { { fontSize : 11.5 , color : 'var(--text-3)' , marginTop : 2 } } > { r . notes } < / div >
< / div >
< a href = { r . zxp } download style = { { textDecoration : 'none' } } >
< button className = "btn ghost sm" > ZXP < / button >
< / a >
< a href = { r . installer } download style = { { textDecoration : 'none' } } >
< button className = "btn ghost sm" > Win Installer < / button >
< / a >
< / div >
) ) }
< / div >
< / div >
< div style = { { borderTop : '1px solid var(--border)' , marginBottom : 14 } } / >
{ /* ── Capture SDK upload section ── */ }
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
< div style = { { fontSize : 12 , color : 'var(--text-3)' , lineHeight : 1.55 , marginBottom : 4 } } >
Each SDK archive should be a < strong style = { { color : 'var(--text-2)' } } > . zip < / strong > or < strong style = { { color : 'var(--text-2)' } } > . tar . gz < / strong > containing the vendor ' s Linux SDK contents . After uploading , rebuild the capture container on the host with a DeckLink / AJA / Deltacast card . The SDK files are staged under < code className = "mono" style = { { fontSize : 11.5 } } > / sdk / & lt ; vendor & gt ; / < / c o d e > i n s i d e m a m - a p i .
< / div >
< SettingsMsg msg = { msg } / >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 10 } } >
{ SDK _VENDORS . map ( v => (
< SdkVendorRow
key = { v . id }
vendor = { v }
status = { ( statuses && statuses [ v . id ] ) || null }
onDone = { ( text , ok = true ) => { setMsg ( { ok , text } ) ; load ( ) ; } }
/ >
) ) }
< / div >
< / SettingsCard >
) ;
}
function SdkVendorRow ( { vendor , status , onDone } ) {
const fileRef = React . useRef ( null ) ;
const [ uploading , setUploading ] = React . useState ( false ) ;
const [ progress , setProgress ] = React . useState ( 0 ) ;
const deployed = status && status . file _count > 0 ;
const lastUpload = status ? . uploaded _at
? new Date ( status . uploaded _at ) . toLocaleString ( )
: null ;
const handleFile = async ( file ) => {
if ( ! file ) return ;
setUploading ( true ) ; setProgress ( 0 ) ;
const fd = new FormData ( ) ;
fd . append ( 'archive' , file ) ;
// Use XHR so we can report progress to the user — fetch's stream API is fiddly.
await new Promise ( ( resolve ) => {
const xhr = new XMLHttpRequest ( ) ;
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
xhr . open ( 'POST' , ( window . ZAMPP _API _PREFIX || '/api/v1' ) + '/sdk/' + vendor . id ) ;
feat: SDK deployment UI, proxy encoding global settings, S3 env fallback
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
2026-05-22 22:58:32 -04:00
xhr . withCredentials = true ;
xhr . upload . onprogress = ( e ) => {
if ( e . lengthComputable ) setProgress ( Math . round ( ( e . loaded / e . total ) * 100 ) ) ;
} ;
xhr . onload = ( ) => {
setUploading ( false ) ; setProgress ( 0 ) ;
if ( xhr . status >= 200 && xhr . status < 300 ) {
onDone ( vendor . name + ': SDK staged.' , true ) ;
} else {
let txt = xhr . responseText ;
try { txt = JSON . parse ( xhr . responseText ) . error || txt ; } catch { }
onDone ( vendor . name + ': upload failed — ' + txt , false ) ;
}
resolve ( ) ;
} ;
xhr . onerror = ( ) => {
setUploading ( false ) ; setProgress ( 0 ) ;
onDone ( vendor . name + ': network error' , false ) ;
resolve ( ) ;
} ;
xhr . send ( fd ) ;
} ) ;
} ;
const clear = ( ) => {
if ( ! confirm ( 'Remove staged ' + vendor . name + ' SDK files?' ) ) return ;
window . ZAMPP _API . fetch ( '/sdk/' + vendor . id , { method : 'DELETE' } )
. then ( ( ) => onDone ( vendor . name + ': cleared.' , true ) )
. catch ( e => onDone ( vendor . name + ': ' + e . message , false ) ) ;
} ;
return (
< div style = { { border : '1px solid var(--border)' , borderRadius : 6 , padding : 12 , background : 'var(--bg-2)' } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 , marginBottom : 4 } } >
< strong style = { { fontSize : 13 } } > { vendor . name } < / strong >
{ deployed
? < span className = "badge success" > deployed · { status . file _count } files < / span >
: < span className = "badge neutral" > not deployed < / span > }
{ vendor . status === 'staging-only' && < span className = "badge warning" title = { vendor . buildHint } > build pipeline pending < / span > }
< div style = { { flex : 1 } } / >
{ deployed && < button className = "btn ghost sm" onClick = { clear } > Remove < / button > }
< button className = "btn primary sm" onClick = { ( ) => fileRef . current ? . click ( ) } disabled = { uploading } >
{ uploading ? ` Uploading ${ progress } % ` : ( deployed ? 'Replace' : 'Upload SDK' ) }
< / button >
< input ref = { fileRef } type = "file" accept = ".zip,.tar.gz,.tgz,.tar"
style = { { display : 'none' } }
onChange = { e => handleFile ( e . target . files ? . [ 0 ] ) } / >
< / div >
< div style = { { fontSize : 11.5 , color : 'var(--text-3)' , lineHeight : 1.55 } } >
{ vendor . sub } < br / >
< span className = "mono" style = { { fontSize : 11 } } > expects : { vendor . expect } < / span >
{ lastUpload && < > < br / > < span style = { { color : 'var(--text-3)' } } > uploaded : { lastUpload } < / span > < / > }
{ deployed && < > < br / > < span className = "mono" style = { { fontSize : 11 , color : 'var(--text-3)' } } > on host : rebuild with → { vendor . buildHint } < / span > < / > }
< / 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
function AmppSettingsCard ( ) {
const [ cfg , setCfg ] = React . useState ( null ) ;
const [ saving , setSaving ] = React . useState ( false ) ;
const [ testing , setTesting ] = React . useState ( false ) ;
const [ msg , setMsg ] = React . useState ( null ) ;
const [ tokenExists , setTokenExists ] = React . useState ( false ) ;
React . useEffect ( ( ) => {
window . ZAMPP _API . fetch ( '/settings/ampp' ) . then ( d => {
setCfg ( { ampp _base _url : d . ampp _base _url || '' , ampp _token : '' } ) ;
setTokenExists ( ! ! d . ampp _token _exists ) ;
} ) . catch ( ( ) => setCfg ( { ampp _base _url : '' , ampp _token : '' } ) ) ;
} , [ ] ) ;
const save = ( ) => {
setSaving ( true ) ; setMsg ( null ) ;
window . ZAMPP _API . fetch ( '/settings/ampp' , { method : 'PUT' , body : JSON . stringify ( cfg ) } )
. then ( ( ) => { setSaving ( false ) ; setMsg ( { ok : true , text : 'Saved.' } ) ; if ( cfg . ampp _token ) setTokenExists ( true ) ; } )
. catch ( e => { setSaving ( false ) ; setMsg ( { ok : false , text : e . message } ) ; } ) ;
} ;
const test = ( ) => {
setTesting ( true ) ; setMsg ( null ) ;
window . ZAMPP _API . fetch ( '/settings/ampp/test' , { method : 'POST' , body : JSON . stringify ( cfg ) } )
. then ( r => { setTesting ( false ) ; setMsg ( { ok : true , text : r . message || 'Connection OK' } ) ; } )
. catch ( e => { setTesting ( false ) ; setMsg ( { ok : false , text : e . message } ) ; } ) ;
} ;
if ( ! cfg ) return < SettingsCard icon = "link" title = "AMPP integration" sub = "Loading…" > < div style = { { color : 'var(--text-3)' } } > … < / div > < / SettingsCard > ;
return (
< SettingsCard icon = "link" title = "AMPP integration" sub = "Migrate assets and metadata from Grass Valley AMPP"
tag = { tokenExists ? < span className = "badge success" > connected < / span > : < span className = "badge neutral" > not configured < / 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
< form onSubmit = { e => { e . preventDefault ( ) ; save ( ) ; } } autoComplete = "off" >
< SField label = "AMPP base URL" >
< input className = "field-input mono" type = "url" required value = { cfg . ampp _base _url } onChange = { e => setCfg ( p => ( { ... p , ampp _base _url : e . target . value } ) ) } placeholder = "https://my-org.gvampp.tv" / >
< / SField >
< SField label = "API token" >
< input className = "field-input mono" type = "password" value = { cfg . ampp _token } onChange = { e => setCfg ( p => ( { ... p , ampp _token : e . target . value } ) ) } placeholder = { tokenExists ? '(saved — type to replace)' : 'AMPP API token' } autoComplete = "new-password" / >
< / SField >
< SettingsMsg msg = { msg } / >
< div style = { { display : 'flex' , gap : 6 , marginTop : 8 } } >
< button type = "submit" className = "btn primary sm" disabled = { saving } > { saving ? 'Saving…' : 'Save' } < / button >
< button type = "button" className = "btn ghost sm" onClick = { test } disabled = { testing } > { testing ? 'Testing…' : 'Test connection' } < / button >
< / div >
< / form >
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
< / SettingsCard >
) ;
}
function SettingsMsg ( { msg } ) {
if ( ! msg ) return null ;
return (
< div style = { { fontSize : 12 , padding : '6px 10px' , borderRadius : 5 , border : '1px solid' ,
background : msg . ok ? 'var(--success-soft)' : 'var(--danger-soft)' ,
borderColor : msg . ok ? 'var(--success)' : 'var(--danger)' ,
color : msg . ok ? 'var(--success)' : 'var(--danger)' } } >
{ msg . text }
< / div >
) ;
}
2026-05-22 12:08:10 -04:00
function SField ( { label , children } ) {
return (
< div className = "field" >
< label className = "field-label" > { label } < / label >
{ children }
< / div >
) ;
}
2026-05-22 08:22:38 -04:00
function SettingsCard ( { icon , title , sub , tag , children } ) {
return (
< div className = "settings-card" >
< div className = "settings-card-head" >
< div className = "settings-card-icon" > < Icon name = { icon } size = { 16 } / > < / div >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontWeight : 600 , fontSize : 14 } } > { title } < / div >
< div style = { { fontSize : 12 , color : "var(--text-3)" , marginTop : 2 } } > { sub } < / div >
< / div >
{ tag }
< / div >
< div className = "settings-card-body" > { children } < / div >
< / div >
) ;
}
Object . assign ( window , { Users , Tokens , Containers , Cluster , Settings } ) ;