// screens-admin.jsx — Users, Tokens, Containers, Cluster (graph), Settings 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, }; } 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 (
e.stopPropagation()}>
Invite user
setForm(p => ({...p, username: e.target.value}))} onKeyDown={onKey} placeholder="jsmith" />
setForm(p => ({...p, display_name: e.target.value}))} onKeyDown={onKey} placeholder="John Smith" />
setForm(p => ({...p, password: e.target.value}))} onKeyDown={onKey} placeholder="Temporary password" />
{err &&
{err}
}
); } function Users() { const [users, setUsers] = React.useState(window.ZAMPP_DATA.USERS || []); const [groups, setGroups] = React.useState([]); const [tab, setTab] = React.useState("users"); const [showInvite, setShowInvite] = React.useState(false); const [editingUser, setEditingUser] = React.useState(null); const [resetUser, setResetUser] = 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]); const exportCsv = () => { const rows = [['Username', 'Name', 'Role', 'Groups', 'Created']].concat( users.map(u => [u.username || '', u.name || '', u.role || '', u.group_count || 0, u.created_at || '']) ); 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 = () => { 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); setResetUser(u); }; 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)); }; return (

Users & Groups

{tab === 'users' && (<> )}
{tab === 'users' && (
User
Role
Groups
Created
{users.length === 0 && (
No users found
)} {users.map(u => (
{u.initials || '??'}
{u.name}
@{u.username}
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
{menuFor === u.id && (
e.stopPropagation()}>
)}
))}
)} {tab === 'groups' && } {tab === 'policies' && (
Access policies
Per-project and per-bin permissions are coming soon. For now, role-based access
(admin / editor / viewer) is enforced API-wide.
)}
{showInvite && setShowInvite(false)} />} {editingUser && ( setEditingUser(null)} onSaved={() => { setEditingUser(null); refreshUsers(); }} /> )} {resetUser && ( setResetUser(null)} onSaved={() => setResetUser(null)} /> )}
); } 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 (
e.stopPropagation()}>
Rename user
setName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submit(); }} />
username @{user.username} cannot be changed
{err &&
{err}
}
); } function PasswordResetModal({ user, onClose, onSaved }) { const [pw, setPw] = React.useState(''); const [pw2, setPw2] = React.useState(''); const [show, setShow] = React.useState(false); const [saving, setSaving] = React.useState(false); const [err, setErr] = React.useState(null); const [done, setDone] = React.useState(false); const valid = pw.length >= 8 && pw === pw2; const submit = () => { if (!valid) return; setSaving(true); setErr(null); window.ZAMPP_API.fetch('/users/' + user.id, { method: 'PATCH', body: JSON.stringify({ password: pw }) }) .then(() => { setSaving(false); setDone(true); setTimeout(onSaved, 1200); }) .catch(e => { setSaving(false); setErr(e.message); }); }; return (
e.stopPropagation()}>
Reset password · @{user.username}
{done ? (
Password updated.
) : (<>
setPw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }} style={{ paddingRight: 36 }} />
0 && pw.length < 8 ? 'var(--danger)' : 'var(--text-3)', marginTop: 4 }}> Minimum 8 characters
setPw2(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }} /> {pw2.length > 0 && pw !== pw2 && (
Passwords do not match
)}
{err &&
{err}
} )}
{!done && (
)}
); } 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 (
Groups let you bundle users for project access. Memberships are checked when role-based access is enforced.
{creating && (
setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') createGroup(); }} placeholder="broadcasters" />
setNewDesc(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') createGroup(); }} placeholder="On-air operators" />
)}
{groups.length === 0 && !creating && (
No groups yet — click New group above to create one.
)} {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 (
{g.name}
{g.id.slice(0, 8)}
{g.description || no description}
{g.member_count || 0} member{g.member_count === 1 ? '' : 's'}
{isOpen && (
{groupMembers.length === 0 && No members yet.} {groupMembers.map(m => ( @{m.username} ))}
{nonMembers.length > 0 && (
Add member:
)}
)}
); })}
); } 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 (

Tokens

Token-metered pricing parody · You actually pay $0.00
SATIRE

Per-seat {' · '} Per-stream {' · '} Per-month
Per Token.

TOKENS BURNED THIS SESSION
🔥 {burned.toLocaleString()}
↑ {rate.toFixed(1)}k/sec burning since you logged in
WHAT YOU ACTUALLY PAY
$0 .00
Dragonflight is self-hosted. The tokens above are imaginary.
Imagine them as a stress test for your sanity.
HOURLY BURN — DRAGONFLIGHT vs. THE OTHER GUYS
i < 20 ? 1 : 1), color: "#2DD4A8" }, ]} />
Competitor: $1,247/hr and rising
Dragonflight: $0.00/hr forever
LIVE BILLING EVENTS
{events.map((e, i) => (
{e.t} {e.action} +{e.cost} tk
))}
PRICING TIERS WE DIDN'T COPY
{tiers.map(t => (
{t.popular && MOST PAIN}
{t.name}
{t.desc}
{t.price} {t.per}
{t.tokens}
))}
{showCalc && setShowCalc(false)} />}
Disclaimer: 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.
); } function ChartLine({ series }) { const w = 600, h = 140; return ( {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 ( ); })} ); } 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 (
e.stopPropagation()}>
Token Cost Calculator
What it would cost on AMPP-style pricing
You would be paying
${cost.toLocaleString("en-US", { maximumFractionDigits: 0 })}/ month
Your actual Dragonflight cost: $0.00. You're welcome.
); } function CalcSlider({ label, value, onChange, min, max, step = 1, unit }) { return (
{label} {value.toLocaleString()}{unit}
onChange(Number(e.target.value))} style={{ width: "100%", accentColor: "var(--accent)" }} />
); } function Containers() { 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; const [restartFlash, setRestartFlash] = React.useState(null); const [logsModal, setLogsModal] = React.useState(null); const showLogs = (c) => setLogsModal(c); const restartContainer = (c) => { if (!window.confirm('Restart container "' + c.name + '"?\nIn-flight requests will be dropped.')) return; setRestartFlash({ name: c.name, status: 'pending' }); window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' }) .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); }); }; return (

Containers

Docker Compose services across the cluster
{containers !== null && containers.length > 0 && (
{running} / {containers.length} running
)}
{restartFlash && (
{restartFlash.status === 'pending' && `Restarting ${restartFlash.name}…`} {restartFlash.status === 'ok' && `${restartFlash.name} restarted.`} {restartFlash.status === 'fail' && `${restartFlash.name}: ${restartFlash.error}`}
)} {logsModal && (
setLogsModal(null)}>
e.stopPropagation()}>
Logs · {logsModal.name}
Live log streaming over the websocket isn't wired yet. SSH to the host that runs this container and tail the logs directly:
docker compose logs -f {logsModal.name}
Or grab the last 200 lines:  docker logs --tail 200 {logsModal.name}
)}
{containers === null && (
Loading…
)} {containers !== null && containers.length === 0 && (
🐳
No containers returned
Confirm /var/run/docker.sock is mounted in the mam-api container
)} {containers !== null && containers.length > 0 && (
Container
Image
State
CPU
Memory
Ports
{containers.map(c => (
{c.name}
up {c.uptime}
{c.image}
RUNNING {c.healthy && healthy}
{(c.cpu || 0).toFixed(1)}%
{c.mem} MB
{c.ports}
))}
)}
); } function Cluster() { 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 || []); 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]; }, [nodesData]); const [selected, setSelected] = React.useState(null); const sel = selected || NODES[0] || null; const W = 720, H = 460; if (!NODES.length) { return (

Cluster

No cluster nodes available
); } const primary = NODES.find(n => n.role === 'primary') || NODES[0]; const edges = NODES.filter(n => n.id !== primary.id).map(n => ({ from: primary, to: n, alive: n.status === 'online', })); 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'`], }); 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 => setAdviceModal({ title: 'Remove failed', lines: [e.message] })); }; 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'`], }); return (

Cluster

{NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online
Live
Nodes
{NODES.length}
Avg CPU
{Math.round(NODES.reduce((a, n) => a + (n.cpu || 0), 0) / NODES.length)} %
GPUs
{NODES.reduce((a, n) => a + n.gpus.length, 0)}
Avg Memory
{Math.round(NODES.reduce((a, n) => a + (n.mem || 0), 0) / NODES.length)} GB
Topology {NODES.length} node{NODES.length === 1 ? '' : 's'}
{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 ( {e.alive && ( )} ); })} {NODES.map(n => { const cx = n.x * W, cy = n.y * H; const isSelected = sel && sel.id === n.id; const color = n.status === "online" ? "var(--success)" : "var(--text-4)"; return ( setHovered(n.id)} onMouseLeave={() => setHovered(null)} onClick={() => setSelected(n)}> {n.status === "online" && ( )} {n.role === "primary" && } {n.role !== "primary" && {n.role[0].toUpperCase()}} {n.id} {n.ip} ); })}
{sel && (
{sel.id} {sel.role}
{sel.status}} />
{sel.cpu}%
} /> {sel.memTotal > 0 && (
{sel.mem} / {sel.memTotal} GB
} /> )} {sel.gpus.length > 0 && (
GPUs ({sel.gpus.length})
{sel.gpus.map((g, i) => (
{g}
))}
)} {sel.devices && sel.devices.length > 0 && (
Capture devices
{sel.devices.map((d, i) => (
{d}
))}
)}
{sel.role !== "primary" && }
)}
{adviceModal && (
setAdviceModal(null)}>
e.stopPropagation()}>
{adviceModal.title}
{(adviceModal.lines || []).map((l, i) => (
{l}
))} {(adviceModal.commands || []).map((c, i) => ( {c} ))}
{adviceModal.commands && adviceModal.commands.length > 0 && ( )}
)}
); } function DetailRow({ k, v, mono }) { return (
{k} {v}
); } function Settings() { const [section, setSection] = React.useState('storage'); const SECTIONS = [ { id: 'storage', label: 'S3 / Object storage', icon: 'hdd' }, { id: 'proxy', label: 'Proxy encoding', icon: 'gpu' }, { id: 'growing', label: 'Growing files (SMB)', icon: 'hdd' }, { id: 'sdk', label: 'Capture SDKs', icon: 'video' }, { id: 'sdi', label: 'SDI capture', icon: 'video' }, ]; return (

Settings

System configuration · changes apply without restart
{section === 'storage' && } {section === 'proxy' && } {section === 'growing' && } {section === 'sdk' && } {section === 'sdi' && }
); } 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 => { // Diagnostic: previous reports of "endpoint always blank" were // hard to chase without seeing the raw payload. Log it once on // load so the next user can verify quickly. try { console.debug('[settings] /settings/s3 →', data); } catch (_) {} setS3({ s3_endpoint: data.s3_endpoint || '', s3_bucket: data.s3_bucket || '', s3_access_key: data.s3_access_key || '', s3_secret_key: '', s3_region: data.s3_region || 'us-east-1', }); setSecretExists(!!data.s3_secret_key_exists); setLoading(false); }) .catch(err => { console.error('[settings] /settings/s3 failed:', err); setMsg({ ok: false, text: 'Could not load S3 settings: ' + (err.message || err) }); 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 ( connected : not configured}> {loading ?
Loading…
: (<> setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /> setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" />
setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /> setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} />
)}
); } 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) }) .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved — new settings apply to the next proxy job.' }); }) .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); }); }; if (!cfg) return
Loading…
; const set = (k, v) => setCfg(c => ({ ...c, [k]: v })); const gpuEnabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true; return ( GPU mode : CPU mode}>
These settings drive the proxy worker for every ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.
set('gpu_bitrate_mbps', e.target.value)} placeholder="10" />
set('gpu_audio_bitrate_kbps', e.target.value)} placeholder="192" />
); } 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
; const set = (k, v) => setCfg(c => ({ ...c, [k]: v })); const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true; return ( enabled : disabled}> set('growing_path', e.target.value)} placeholder="/growing" /> set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" /> set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
); } function SdiSettingsCard() { return ( per-recorder}>
SDI settings are configured per-recorder. Use{' '} Ingest → Recorders → New recorder{' '} to pick the DeckLink port, codec, and audio routing.
); } // ──────────────────────────────────────────────────────────────────────────── // 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 ( {SDK_VENDORS.length} vendors}>
Each SDK archive should be a .zip or .tar.gz 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 /sdk/<vendor>/ inside mam-api.
{SDK_VENDORS.map(v => ( { setMsg({ ok, text }); load(); }} /> ))}
); } 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 (
{vendor.name} {deployed ? deployed · {status.file_count} files : not deployed} {vendor.status === 'staging-only' && build pipeline pending}
{deployed && } handleFile(e.target.files?.[0])} />
{vendor.sub}
expects: {vendor.expect} {lastUpload && <>
uploaded: {lastUpload}} {deployed && <>
on host: rebuild with → {vendor.buildHint}}
); } 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
; return ( connected : not configured}> setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" /> setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} />
); } function SettingsMsg({ msg }) { if (!msg) return null; return (
{msg.text}
); } function SField({ label, children }) { return (
{children}
); } function SettingsCard({ icon, title, sub, tag, children }) { return (
{title}
{sub}
{tag}
{children}
); } Object.assign(window, { Users, Tokens, Containers, Cluster, Settings });