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 ) {
return {
id : n . id || n . hostname || n . name || 'node' ,
role : n . role || 'worker' ,
status : n . status || ( n . online ? 'online' : 'offline' ) ,
ip : n . ip || n . ip _address || '—' ,
version : n . version || '—' ,
uptime : n . uptime || '—' ,
cpu : n . cpu || n . cpu _percent || 0 ,
mem : n . mem || n . memory _used || n . memory _used _gb || 0 ,
memTotal : n . memTotal || n . mem _total || n . memory _total || n . memory _total _gb || 0 ,
gpus : n . gpus || ( n . gpu _count ? Array ( n . gpu _count ) . fill ( 'GPU' ) : [ ] ) ,
devices : n . devices || n . capture _devices || [ ] ,
x , y ,
} ;
}
2026-05-22 08:22:38 -04:00
2026-05-22 12:27:02 -04:00
function InviteUserModal ( { onCreated , onClose } ) {
const [ form , setForm ] = React . useState ( { username : '' , display _name : '' , password : '' , role : 'viewer' } ) ;
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 ) ;
window . ZAMPP _API . fetch ( '/users' , { method : 'POST' , body : JSON . stringify ( form ) } )
. 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 >
< button className = "icon-btn" onClick = { onClose } > < Icon name = "x" / > < / button >
< / 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 }
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 }
onChange = { e => setForm ( p => ( { ... p , role : e . target . value } ) ) }
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 ) ;
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-22 23:30:10 -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 ) ) ;
} ;
const resetPassword = ( u ) => {
setMenuFor ( null ) ;
const pw = prompt ( ` Reset password for ${ u . username } \ n \ nNew password (≥ 8 characters): ` ) ;
if ( ! pw ) return ;
if ( pw . length < 8 ) { alert ( 'Password must be at least 8 characters.' ) ; return ; }
window . ZAMPP _API . fetch ( '/users/' + u . id , { method : 'PATCH' , body : JSON . stringify ( { password : pw } ) } )
. then ( ( ) => alert ( 'Password reset for ' + u . username ) )
. catch ( e => alert ( 'Reset failed: ' + e . message ) ) ;
} ;
const changeRole = ( u , newRole ) => {
if ( u . role === newRole ) return ;
window . ZAMPP _API . fetch ( '/users/' + u . id , { method : 'PATCH' , body : JSON . stringify ( { role : newRole } ) } )
. 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 >
< div > Created < / div >
< 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)" } } >
{ u . created _at ? new Date ( u . created _at ) . toLocaleDateString ( ) : u . lastSeen || '—' }
< / div >
< div style = { { position : 'relative' } } >
< button className = "icon-btn" onClick = { e => { e . stopPropagation ( ) ; setMenuFor ( menuFor === u . id ? null : u . id ) ; } } >
< 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 ( ) ; } }
/ >
) }
< / 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 >
< button className = "icon-btn" onClick = { onClose } > < Icon name = "x" / > < / button >
< / 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 >
) ;
}
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 }
< button className = "icon-btn" style = { { width : 16 , height : 16 , padding : 0 } } onClick = { ( ) => removeMember ( g , m . id ) } title = "Remove" >
< 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 ) ;
const [ showCalc , setShowCalc ] = React . useState ( false ) ;
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 >
< button className = "icon-btn" onClick = { onClose } > < Icon name = "x" / > < / button >
< / 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 ) ;
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 ;
2026-05-23 00:02:51 -04:00
const [ restartFlash , setRestartFlash ] = React . useState ( null ) ;
const [ logsModal , setLogsModal ] = React . useState ( null ) ;
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 ;
setRestartFlash ( { 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' } )
2026-05-23 00:02:51 -04:00
. then ( ( ) => { setRestartFlash ( { name : c . name , status : 'ok' } ) ; load ( ) ; setTimeout ( ( ) => setRestartFlash ( null ) , 3000 ) ; } )
. catch ( e => { setRestartFlash ( { name : c . name , status : 'fail' , error : e . message } ) ; setTimeout ( ( ) => setRestartFlash ( null ) , 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 >
< button className = "icon-btn" onClick = { ( ) => setLogsModal ( null ) } > < Icon name = "x" / > < / button >
< / 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 >
< div style = { { fontWeight : 500 , fontSize : 14 } } > No container data available < / div >
< div style = { { fontSize : 12 , marginTop : 6 } } > Container metrics endpoint not yet wired < / div >
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 >
) ;
}
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 ) ;
const refresh = React . useCallback ( ( ) => {
window . ZAMPP _API . fetch ( '/cluster' )
. then ( data => {
window . ZAMPP _DATA . NODES = data ;
setNodesData ( data ) ;
} )
. catch ( ( ) => { } ) ;
} , [ ] ) ;
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 ;
window . ZAMPP _API . fetch ( '/cluster/nodes/' + encodeURIComponent ( node . id ) , { method : 'DELETE' } )
. 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 >
2026-05-22 10:15:42 -04:00
< div className = "value" > { NODES . reduce ( ( a , n ) => a + n . gpus . length , 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 >
< text textAnchor = "middle" y = "54" fill = "var(--text-3)" fontSize = "10" fontFamily = "var(--font-mono)" > { n . ip } < / text >
< / 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
} / >
) }
{ sel . gpus . length > 0 && (
< div >
< div style = { { fontSize : 11 , color : "var(--text-3)" , marginBottom : 6 } } > GPUs ( { sel . gpus . length } ) < / div >
{ sel . gpus . map ( ( g , i ) => (
< div key = { i } className = "mono" style = { { fontSize : 11.5 , padding : "5px 8px" , background : "var(--bg-2)" , borderRadius : 4 , marginBottom : 4 , display : "flex" , alignItems : "center" , gap : 6 } } >
< Icon name = "gpu" size = { 11 } style = { { color : "var(--text-3)" } } / >
< span > { g } < / span >
< / div >
) ) }
< / div >
) }
{ sel . devices && sel . devices . length > 0 && (
< div >
< div style = { { fontSize : 11 , color : "var(--text-3)" , marginBottom : 6 } } > Capture devices < / div >
{ sel . devices . map ( ( d , i ) => (
< div key = { i } className = "mono" style = { { fontSize : 11.5 , padding : "5px 8px" , background : "var(--bg-2)" , borderRadius : 4 } } >
< Icon name = "video" size = { 11 } style = { { color : "var(--text-3)" , marginRight : 6 } } / > { d }
< / div >
) ) }
< / div >
) }
< 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 >
< button className = "icon-btn" onClick = { ( ) => setAdviceModal ( null ) } > < Icon name = "x" / > < / button >
< / 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 >
) ;
}
function Settings ( ) {
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 [ section , setSection ] = React . useState ( 'storage' ) ;
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 = [
{ id : 'storage' , label : 'S3 / Object storage' , icon : 'hdd' } ,
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
{ id : 'proxy' , label : 'Proxy encoding' , icon : 'gpu' } ,
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
{ id : 'growing' , label : 'Growing files (SMB)' , icon : 'hdd' } ,
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
{ id : 'sdk' , label : 'Capture SDKs' , 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
{ id : 'sdi' , label : 'SDI capture' , icon : 'video' } ,
] ;
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 } } >
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 === 'storage' && < S3SettingsCard / > }
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 / > }
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 === 'growing' && < GrowingSettingsCard / > }
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 === '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 >
) ;
}
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 => {
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' } ) ;
setSecretExists ( ! ! data . s3 _secret _key _exists ) ;
setLoading ( false ) ;
} )
. catch ( ( ) => setLoading ( false ) ) ;
} , [ ] ) ;
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 > } >
{ loading ? < div style = { { color : 'var(--text-3)' , fontSize : 12.5 } } > Loading … < / div > : ( < >
< SField label = "Endpoint URL" >
< input className = "field-input mono" 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" 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" 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" value = { s3 . s3 _access _key } onChange = { e => setS3 ( p => ( { ... p , s3 _access _key : e . target . value } ) ) } placeholder = "Access key ID" / > < / 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' } / > < / SField >
< SettingsMsg msg = { msg } / >
< div style = { { display : 'flex' , gap : 6 , marginTop : 8 } } >
< button className = "btn primary sm" onClick = { save } disabled = { saving } > { saving ? 'Saving…' : 'Save & apply' } < / button >
< button className = "btn ghost sm" onClick = { test } disabled = { testing } > { testing ? 'Testing…' : 'Test connection' } < / button >
< / div >
< / > ) }
< / 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 > } >
< 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 } } >
< button className = "btn primary sm" onClick = { save } disabled = { saving } > { saving ? 'Saving…' : 'Save' } < / button >
< / div >
< / 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 > } >
< 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 } } >
< button className = "btn primary sm" onClick = { save } disabled = { saving } > { saving ? 'Saving…' : 'Save' } < / button >
< / div >
< / 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' ,
} ,
] ;
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 > } >
< 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 ( ) ;
xhr . open ( 'POST' , '/api/v1/sdk/' + vendor . id ) ;
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 > } >
< SField label = "AMPP base URL" >
< input className = "field-input mono" 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' } / >
< / SField >
< SettingsMsg msg = { msg } / >
< div style = { { display : 'flex' , gap : 6 , marginTop : 8 } } >
< button className = "btn primary sm" onClick = { save } disabled = { saving } > { saving ? 'Saving…' : 'Save' } < / button >
< button className = "btn ghost sm" onClick = { test } disabled = { testing } > { testing ? 'Testing…' : 'Test connection' } < / button >
< / div >
< / 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 } ) ;