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 12:27:02 -04:00
const [ users , setUsers ] = React . useState ( window . ZAMPP _DATA . USERS || [ ] ) ;
2026-05-22 08:22:38 -04:00
const [ tab , setTab ] = React . useState ( "users" ) ;
2026-05-22 12:27:02 -04:00
const [ showInvite , setShowInvite ] = React . useState ( false ) ;
const exportCsv = ( ) => {
const rows = [ [ 'Username' , 'Name' , 'Role' , 'Last Seen' ] ] . concat (
users . map ( u => [ u . username || '' , u . name || '' , u . role || '' , u . lastSeen || '' ] )
) ;
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 ( ) ;
} ;
const onCreated = ( user ) => {
const updated = [ ... users , user ] ;
setUsers ( updated ) ;
window . ZAMPP _DATA . USERS = updated ;
} ;
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 12:27:02 -04:00
< 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 10:15:42 -04:00
< button className = { tab === "groups" ? "active" : "" } onClick = { ( ) => setTab ( "groups" ) } > Groups < / button >
< button className = { tab === "policies" ? "active" : "" } onClick = { ( ) => setTab ( "policies" ) } > Policies < / button >
2026-05-22 08:22:38 -04:00
< / div >
< div className = "panel" >
< div className = "user-row head" >
< div > User < / div >
< div > Role < / div >
< div > Groups < / div >
< div > Last active < / div >
< div > < / div >
< / div >
2026-05-22 12:27:02 -04:00
{ users . length === 0 && (
2026-05-22 10:15:42 -04:00
< div style = { { padding : "32px 0" , textAlign : "center" , color : "var(--text-3)" } } > No users found < / div >
) }
2026-05-22 12:27:02 -04:00
{ users . map ( u => (
2026-05-22 08:22:38 -04:00
< div key = { u . id } className = "user-row" >
< div style = { { display : "flex" , alignItems : "center" , gap : 10 } } >
2026-05-22 10:15:42 -04:00
< div className = "avatar" style = { { width : 32 , height : 32 , fontSize : 11 , background : avatarColor ( u . initials || u . id ) } } > { u . initials || '??' } < / div >
2026-05-22 08:22:38 -04:00
< div >
2026-05-22 10:15:42 -04:00
< div style = { { fontWeight : 500 , fontSize : 13 } } > { u . name } < / div >
2026-05-22 08:22:38 -04:00
< div className = "mono" style = { { fontSize : 11 , color : "var(--text-3)" } } > @ { u . username } < / div >
< / div >
< / div >
< div > < span className = { ` badge ${ u . role === "admin" ? "purple" : u . role === "service" ? "neutral" : "accent" } ` } > { u . role } < / span > < / div >
< div style = { { display : "flex" , gap : 4 , flexWrap : "wrap" } } >
2026-05-22 10:15:42 -04:00
{ ( u . groups || [ ] ) . map ( g => < span key = { g } className = "badge outline" style = { { textTransform : "lowercase" } } > { g } < / span > ) }
2026-05-22 08:22:38 -04:00
< / div >
2026-05-22 10:15:42 -04:00
< div className = "mono" style = { { fontSize : 11.5 , color : "var(--text-3)" } } > { u . lastSeen } < / div >
2026-05-22 08:22:38 -04:00
< div > < button className = "icon-btn" > < Icon name = "more" / > < / button > < / div >
< / div >
) ) }
< / div >
< / div >
2026-05-22 12:27:02 -04:00
{ showInvite && < InviteUserModal onCreated = { onCreated } onClose = { ( ) => setShowInvite ( false ) } / > }
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
credentials are managed through the cluster ' s own JWT issuer ( see < span style = { { color : "var(--accent-text)" } } > Settings → AMPP Integration < / span > ) .
< / 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-22 12:27:02 -04:00
const showLogs = ( c ) => {
alert ( 'To view logs for ' + c . name + ', run:\n\ndocker compose logs -f ' + c . name ) ;
} ;
const restartContainer = ( c ) => {
if ( ! window . confirm ( 'Restart container ' + c . name + '?' ) ) return ;
window . ZAMPP _API . fetch ( '/cluster/containers/' + encodeURIComponent ( c . id || c . name ) + '/restart' , { method : 'POST' } )
. then ( ( ) => { alert ( c . name + ' restarted.' ) ; load ( ) ; } )
. catch ( ( ) => alert ( 'No restart endpoint available.\nRun manually:\n\ndocker compose restart ' + c . name ) ) ;
} ;
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 >
< 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
const addNode = ( ) => {
alert ( 'To add a worker node:\n\n1. Install Docker + docker-compose on the target machine\n2. Copy /opt/wild-dragon to that machine\n3. Set NODE_ROLE=worker in the .env file\n4. Run: docker compose up -d\n\nThe node will register with this cluster automatically.' ) ;
} ;
const drainNode = ( node ) => {
alert ( 'Drain is not yet automated.\n\nTo drain ' + node . id + ':\n1. Stop new jobs from routing to this node\n2. Wait for in-progress jobs to complete\n3. Then remove the node safely' ) ;
} ;
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 ( ) )
. catch ( e => alert ( 'Remove failed: ' + e . message ) ) ;
} ;
const nodeLogsHint = ( node ) => {
alert ( 'To view logs for ' + node . id + ' (' + node . ip + '):\n\nSSH to ' + node . ip + ' and run:\ndocker compose -f /opt/wild-dragon/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 >
< div className = "tab-group" >
< button className = "active" > Graph < / button >
< button > List < / button >
< / div >
< / 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 >
< / 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 ( ) {
2026-05-22 12:08:10 -04:00
const [ s3 , setS3 ] = React . useState ( { s3 _endpoint : '' , s3 _bucket : '' , s3 _access _key : '' , s3 _secret _key : '' , s3 _region : 'us-east-1' } ) ;
const [ s3Loading , setS3Loading ] = React . useState ( true ) ;
const [ s3Saving , setS3Saving ] = React . useState ( false ) ;
const [ s3Testing , setS3Testing ] = React . useState ( false ) ;
const [ s3Msg , setS3Msg ] = 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 ) ;
setS3Loading ( false ) ;
} )
. catch ( ( ) => setS3Loading ( false ) ) ;
} , [ ] ) ;
const saveS3 = ( ) => {
setS3Saving ( true ) ; setS3Msg ( null ) ;
window . ZAMPP _API . fetch ( '/settings/s3' , { method : 'PUT' , body : JSON . stringify ( s3 ) } )
. then ( ( ) => { setS3Saving ( false ) ; setS3Msg ( { ok : true , text : 'Saved and applied.' } ) ; if ( s3 . s3 _secret _key ) setSecretExists ( true ) ; } )
. catch ( e => { setS3Saving ( false ) ; setS3Msg ( { ok : false , text : e . message } ) ; } ) ;
} ;
const testS3 = ( ) => {
setS3Testing ( true ) ; setS3Msg ( null ) ;
window . ZAMPP _API . fetch ( '/settings/s3/test' , { method : 'POST' , body : JSON . stringify ( s3 ) } )
. then ( r => { setS3Testing ( false ) ; setS3Msg ( { ok : r . ok !== false , text : r . message || 'Connection OK' } ) ; } )
. catch ( e => { setS3Testing ( false ) ; setS3Msg ( { ok : false , text : e . message } ) ; } ) ;
} ;
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" >
{ [
2026-05-22 12:08:10 -04:00
{ id : 'storage' , label : 'S3 / Object storage' , icon : 'hdd' } ,
{ id : 'gpu' , label : 'GPU / Transcoding' , icon : 'gpu' } ,
{ id : 'sdi' , label : 'SDI capture' , icon : 'video' } ,
{ id : 'ampp' , label : 'AMPP integration' , icon : 'link' } ,
2026-05-22 08:22:38 -04:00
] . map ( ( s , i ) => (
2026-05-22 12:08:10 -04:00
< a key = { s . id } className = { ` settings-nav-item ${ i === 0 ? 'active' : '' } ` } >
2026-05-22 08:22:38 -04:00
< Icon name = { s . icon } size = { 14 } / > { s . label }
< / a >
) ) }
< / nav >
2026-05-22 12:08:10 -04:00
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 16 } } >
2026-05-22 08:22:38 -04:00
< SettingsCard
icon = "hdd"
title = "S3 / Object Storage"
sub = "S3-compatible bucket for media asset storage"
2026-05-22 12:08:10 -04:00
tag = { secretExists ? < span className = "badge success" > connected < / span > : < span className = "badge warning" > not configured < / span > }
2026-05-22 08:22:38 -04:00
>
2026-05-22 12:08:10 -04:00
{ s3Loading ? (
< 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 >
{ s3Msg && (
< div style = { { fontSize : 12 , padding : '6px 10px' , borderRadius : 5 , border : '1px solid' , background : s3Msg . ok ? 'var(--success-soft)' : 'var(--danger-soft)' , borderColor : s3Msg . ok ? 'var(--success)' : 'var(--danger)' , color : s3Msg . ok ? 'var(--success)' : 'var(--danger)' } } >
{ s3Msg . text }
< / div >
) }
< div style = { { display : 'flex' , gap : 6 , marginTop : 8 } } >
< button className = "btn primary sm" onClick = { saveS3 } disabled = { s3Saving } > { s3Saving ? 'Saving…' : 'Save & apply' } < / button >
< button className = "btn ghost sm" onClick = { testS3 } disabled = { s3Testing } > { s3Testing ? 'Testing…' : 'Test connection' } < / button >
< / div >
< / > ) }
2026-05-22 08:22:38 -04:00
< / SettingsCard >
< / div >
< / div >
< / div >
< / 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 } ) ;