// screens-admin.jsx - Users, Tokens, Containers, Cluster (graph), Settings function _normalizeNode(n, x, y) { const cap = n.capabilities || {}; // GPUs: capabilities.gpus entries with name+memory_mb = driver-bound (nvidia-smi confirmed). // Entries with only type+device = detected by /dev file but driver status unknown. const gpus = (cap.gpus || []).map(g => ({ name: g.name || (g.type ? g.type.toUpperCase() : 'GPU'), memMb: g.memory_mb || null, index: g.index ?? 0, device: g.device || null, bound: !!(g.name && g.memory_mb), // name+memory = nvidia-smi confirmed driver bound })); // Blackmagic DeckLink: capabilities.blackmagic + capabilities.blackmagic_model const bmdPorts = (cap.blackmagic || []).map(b => ({ index: b.index ?? 0, device: b.device || null, model: cap.blackmagic_model || null, online: b.online !== false, })); const memUsedMb = n.mem_used_mb || n.memory_used_mb || (n.mem && n.mem < 1000 ? n.mem * 1024 : n.mem || 0); const memTotalMb = n.mem_total_mb || n.memory_total_mb || (n.memTotal && n.memTotal < 1000 ? n.memTotal * 1024 : n.memTotal || 0); return { id: n.hostname || n.id || n.name || 'node', dbId: n.id, role: n.role || 'worker', status: n.status || (n.online ? 'online' : 'offline'), ip: n.ip_address || n.ip || '·', version: n.version || '·', uptime: n.uptime || '·', cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0), mem: Math.round(memUsedMb / 1024 * 10) / 10, memTotal: Math.round(memTotalMb / 1024 * 10) / 10, // Raw capabilities for the hardware panel gpus, bmdPorts, // Legacy flat arrays kept for the stat-row summary cards gpuCount: gpus.length, bmdCount: bmdPorts.length, 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); // #111 - guard async resolution / delayed onSaved against unmount. const mountedRef = React.useRef(true); const savedTimerRef = React.useRef(null); React.useEffect(() => () => { mountedRef.current = false; if (savedTimerRef.current) clearTimeout(savedTimerRef.current); }, []); 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(() => { if (!mountedRef.current) return; setSaving(false); setDone(true); savedTimerRef.current = setTimeout(() => { if (mountedRef.current) onSaved(); }, 1200); }) .catch(e => { if (mountedRef.current) { setSaving(false); setErr(e.message); } }); }; 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:
)}
)}
); })}
); } // Real Tokens admin page: wraps ApiTokensSection (defined further down) in a // .page shell so it can be a top-level admin nav destination. The old parody // pricing page lives below as TokensParody and is now routed at /billing in // the Admin section. function Tokens() { return (

Tokens

API tokens for the Premiere panel, node-agents, and external integrations
); } function TokensParody() { 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 (

Billing

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); const [restartFlashState, setRestartFlashState] = React.useState(null); const [logsModalState, setLogsModalState] = React.useState(null); // #111 - guard restart-flash timers against unmount. const mountedRef = React.useRef(true); const flashTimerRef = React.useRef(null); React.useEffect(() => () => { mountedRef.current = false; if (flashTimerRef.current) clearTimeout(flashTimerRef.current); }, []); const setRestartFlashSafe = (v) => { if (mountedRef.current) setRestartFlashState(v); }; const scheduleFlashClear = (ms) => { if (flashTimerRef.current) clearTimeout(flashTimerRef.current); flashTimerRef.current = setTimeout(() => setRestartFlashSafe(null), ms); }; 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 = restartFlashState; const logsModal = logsModalState; const setLogsModal = setLogsModalState; const showLogs = (c) => setLogsModal(c); const restartContainer = (c) => { if (!window.confirm('Restart container "' + c.name + '"?\nIn-flight requests will be dropped.')) return; setRestartFlashSafe({ name: c.name, status: 'pending' }); window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' }) .then(() => { if (!mountedRef.current) return; setRestartFlashSafe({ name: c.name, status: 'ok' }); load(); scheduleFlashClear(3000); }) .catch(e => { setRestartFlashSafe({ name: c.name, status: 'fail', error: e.message }); scheduleFlashClear(5000); }); }; 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}
))}
)}
); } // ──────────────────────────────────────────────────────────────────────────── // BmdCardPanel - capture-card section inside the Cluster node detail panel. // Shows port chips with live video-presence dots AND the BMD SVG card diagram. // ──────────────────────────────────────────────────────────────────────────── function BmdCardPanel({ sel, portSignals }) { const svgRef = React.useRef(null); // Build the port-index → signal-entry map for the selected node. const nodeSignalMap = React.useMemo(() => { const map = new Map(); sel.bmdPorts.forEach((p) => { const key = `${sel.dbId}:${p.index}`; const entry = portSignals[key]; if (entry) map.set(p.index, entry.signal); }); return map; }, [sel.dbId, sel.bmdPorts, portSignals]); // (Re-)render the SVG card diagram whenever the node or signals change. React.useEffect(() => { if (!svgRef.current || !window.BMDCards) return; if (sel.bmdPorts.length === 0) return; svgRef.current.innerHTML = ''; const svg = window.BMDCards.render({ model: sel.bmdPorts[0].model || '', deviceCount: sel.bmdCount, compact: true, portSignals: nodeSignalMap, }); svgRef.current.appendChild(svg); }, [sel.dbId, sel.bmdCount, nodeSignalMap]); return (
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '(none reported)'}
{sel.bmdPorts.length === 0 && (
No DeckLink cards detected on this node
)} {sel.bmdPorts.length > 0 && (
{/* Card header */}
{sel.bmdPorts[0].model || "Blackmagic DeckLink"} {sel.bmdCount} PORT{sel.bmdCount !== 1 ? 'S' : ''}
{/* Port chips with signal state */}
{sel.bmdPorts.map((p) => { const sigEntry = portSignals[`${sel.dbId}:${p.index}`]; const sig = sigEntry ? sigEntry.signal : (p.online !== false ? null : 'offline'); const { label, color } = _signalChip(sig); const isReceiving = sig === 'receiving'; return (
{/* Signal presence dot */} {p.device ? p.device.split('/').pop() : `port ${p.index}`} {sig && ( {label} )} {sigEntry && sigEntry.currentFps != null && ( {Number(sigEntry.currentFps).toFixed(1)} fps )}
); })}
{/* BMD SVG card diagram */}
)}
); } // Signal state → { label, color } for the port chip indicator. function _signalChip(sig) { switch (sig) { case 'receiving': return { label: 'RECEIVING', color: 'var(--success)' }; case 'connecting': return { label: 'CONNECTING', color: 'var(--accent)' }; case 'lost': return { label: 'LOST', color: 'var(--danger)' }; case 'error': return { label: 'ERROR', color: 'var(--danger)' }; case 'idle': return { label: 'IDLE', color: 'var(--text-3)' }; case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' }; default: return { label: sig || '·', color: 'var(--text-4)' }; } } function Cluster() { const [nodesData, setNodesData] = React.useState(window.ZAMPP_DATA.NODES); const [hovered, setHovered] = React.useState(null); // Map of "node_id:portIndex" → signal entry from /cluster/devices/blackmagic/signal const [portSignals, setPortSignals] = React.useState({}); const refresh = React.useCallback(() => { window.ZAMPP_API.fetch('/cluster') .then(data => { window.ZAMPP_DATA.NODES = data; setNodesData(data); }) .catch(() => {}); }, []); // Poll live video-presence state for all DeckLink ports every 5 s. React.useEffect(() => { const poll = () => { window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal') .then(entries => { const map = {}; (entries || []).forEach(e => { map[`${e.node_id}:${e.index}`] = e; }); setPortSignals(map); }) .catch(() => {}); }; poll(); const id = setInterval(poll, 5000); return () => clearInterval(id); }, []); 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.dbId || 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.gpuCount, 0)}
Capture ports
{NODES.reduce((a, n) => a + n.bmdCount, 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} {(n.gpuCount > 0 || n.bmdCount > 0) && ( {[n.gpuCount > 0 && `${n.gpuCount}×GPU`, n.bmdCount > 0 && `${n.bmdCount}×BMD`].filter(Boolean).join(' ')} )} ); })}
{sel && (
{sel.id} {sel.role}
{sel.status}} />
{sel.cpu}%
} /> {sel.memTotal > 0 && (
{sel.mem} / {sel.memTotal} GB
} /> )} {/* ── GPU hardware ── */}
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '(none reported)'}
{sel.gpus.length === 0 && (
No GPUs detected on this node
)} {sel.gpus.map((g, i) => (
{g.name}
{g.memMb && (
{g.memMb >= 1024 ? (g.memMb / 1024).toFixed(1) + ' GB' : g.memMb + ' MB'} VRAM
)} {g.device &&
{g.device}
}
{g.bound ? "BOUND" : "UNBOUND"}
))}
{/* ── Capture cards ── */}
{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 AccountSection() { const [current, setCurrent] = React.useState(''); const [next, setNext] = React.useState(''); const [confirm, setConfirm] = React.useState(''); const [msg, setMsg] = React.useState(null); // { kind: 'ok'|'err', text } const [busy, setBusy] = React.useState(false); const submit = async () => { setMsg(null); if (next !== confirm) { setMsg({ kind: 'err', text: 'Passwords do not match' }); return; } if (next.length < 12) { setMsg({ kind: 'err', text: 'New password must be at least 12 characters' }); return; } setBusy(true); try { const r = await fetch('/api/v1/auth/password', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ current_password: current, new_password: next }), }); if (r.status === 204) { setMsg({ kind: 'ok', text: 'Password updated' }); setCurrent(''); setNext(''); setConfirm(''); } else { const body = await r.json().catch(() => ({})); setMsg({ kind: 'err', text: body.error || 'Failed (' + r.status + ')' }); } } finally { setBusy(false); } }; return (

Account

setCurrent(e.target.value)} /> setNext(e.target.value)} /> setConfirm(e.target.value)} />
{msg && (
{msg.text}
)}
); } function ApiTokensSection() { const [tokens, setTokens] = React.useState([]); const [name, setName] = React.useState(''); const [justCreated, setJustCreated] = React.useState(null); // { token, prefix, name } const [busy, setBusy] = React.useState(false); const load = React.useCallback(async () => { const r = await fetch('/api/v1/auth/tokens', { credentials: 'include' }); if (r.status === 200) setTokens(await r.json()); }, []); React.useEffect(() => { load(); }, [load]); const create = async () => { if (!name.trim()) return; setBusy(true); try { const r = await fetch('/api/v1/auth/tokens', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ name: name.trim() }), }); if (r.status === 201) { const created = await r.json(); setJustCreated(created); setName(''); await load(); } } finally { setBusy(false); } }; const revoke = async (id) => { await fetch('/api/v1/auth/tokens/' + id, { method: 'DELETE', credentials: 'include', headers: { 'X-Requested-With': 'dragonflight-ui' }, }); await load(); }; return (

API Tokens

{justCreated && (
Save this token now: it will not be shown again
{justCreated.token}
)}
setName(e.target.value)} style={{ flex: 1 }} />
{tokens.length === 0 &&
No tokens yet.
} {tokens.map(t => (
{t.name}
{t.prefix}…
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}
))}
); } function Settings() { const [section, setSection] = React.useState('account'); const SECTIONS = [ { id: 'account', label: 'Account', icon: 'user' }, { id: 'storage', label: 'Storage', icon: 'hdd' }, { id: 'proxy', label: 'Proxy encoding', icon: 'gpu' }, { id: 'sdk', label: 'Capture SDKs', icon: 'video' }, { id: 'sdi', label: 'SDI capture', icon: 'video' }, ]; return (

Settings

System configuration · changes apply without restart
{section === 'account' && ( <> )} {section === 'storage' && } {section === 'proxy' && } {section === 'sdk' && } {section === 'sdi' && }
); } // ──────────────────────────────────────────────────────────────────────────── // Storage - unified view: live mount/bucket health on top, then the two // existing editors (S3 bucket + growing-files SMB landing zone) stacked. // ──────────────────────────────────────────────────────────────────────────── function StorageSection() { return ( <> ); } function formatBytes(n) { if (n == null || isNaN(n)) return '·'; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let v = n, i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`; } function HealthPill({ ok, label, detail }) { const cls = ok ? 'badge success' : 'badge warning'; return ( {label} ); } function MountHealthStrip() { const [data, setData] = React.useState(null); const [error, setError] = React.useState(null); const [refreshing, setRefresh] = React.useState(false); const load = React.useCallback(() => { setRefresh(true); window.ZAMPP_API.fetch('/storage/overview') .then(d => { setData(d); setError(null); }) .catch(e => setError(e.message || String(e))) .finally(() => setRefresh(false)); }, []); React.useEffect(() => { load(); // Light auto-refresh so free-space + reachability stay current while the // operator is on the page. 15s is plenty - these are diagnostic, not real-time. const t = setInterval(load, 15_000); return () => clearInterval(t); }, [load]); if (error) { return ( unavailable}> ); } if (!data) { return (
Probing…
); } const g = data.growing; const s = data.s3; const growingHealthy = g.enabled ? (g.exists && g.writable) : true; return ( {refreshing ? '…' : 'Refresh'} }> {/* ── Growing-files row ─────────────────────────────────────────────── */}
Growing files {g.enabled ? : disabled} {g.enabled && g.exists && ( )} {g.free_bytes != null && ( {formatBytes(g.free_bytes)} free )}
Container{g.container_path || '·'} Host{g.host_path || '·'} SMB{g.smb_url || '·'} Promote idle{g.promote_after_seconds}s {g.error && <>Error{g.error}}
{/* ── S3 bucket row ─────────────────────────────────────────────────── */}
S3 bucket {s.head_latency_ms != null && ( {s.head_latency_ms} ms )} {s.probe_method && {s.probe_method}}
Endpoint{s.endpoint || '(AWS default)'} Bucket{s.bucket || '·'} Region{s.region || '·'} {s.error && <>Error{s.error}}
); } 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…
: (
{ e.preventDefault(); save(); }} autoComplete="off"> 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" autoComplete="off" /> setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved: type to replace)' : 'Secret key'} autoComplete="new-password" />
)}
); } 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}>
{ e.preventDefault(); save(); }} autoComplete="off">
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}>
{ e.preventDefault(); save(); }} autoComplete="off"> 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', }, ]; // Premiere panel releases - single source of truth lives on `window.PREMIERE_RELEASES` // (see data.jsx). Local alias for readability. const PREMIERE_RELEASES = window.PREMIERE_RELEASES; 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}> {/* ── Premiere Panel download section ── */}
Premiere Pro Panel
The Dragonflight CEP panel enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro. Install the .zxp via ZXP Installer (Mac/Win), or run the Windows Setup which bundles the installer automatically.
{PREMIERE_RELEASES.map(r => (
v{r.version} {r.latest && latest}
{r.notes}
))}
{/* ── Capture SDK upload section ── */}
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', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id); xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui'); 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}>
{ e.preventDefault(); save(); }} autoComplete="off"> 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'} autoComplete="new-password" />
); } 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 });