// 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()}>
Cancel
{saving ? 'Creating…' : 'Create user'}
);
}
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' && (<>
Export
setShowInvite(true)}> Invite user
>)}
setTab("users")}>Users · {users.length}
setTab("groups")}>Groups · {groups.length}
setTab("policies")}>Policies
{tab === 'users' && (
{users.length === 0 && (
No users found
)}
{users.map(u => (
changeRole(u, e.target.value)}
className="field-input"
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
admin
editor
viewer
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '·'}
{ e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
{menuFor === u.id && (
e.stopPropagation()}>
{ setMenuFor(null); setEditingUser(u); }}>
Rename
resetPassword(u)}>
Reset password
deleteUser(u)}>
Delete
)}
))}
)}
{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()}>
Cancel
{saving ? 'Saving…' : 'Save'}
);
}
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.
) : (<>
Confirm password
setPw2(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }} />
{pw2.length > 0 && pw !== pw2 && (
Passwords do not match
)}
{err &&
{err}
}
>)}
{!done && (
Cancel
{saving ? 'Saving…' : 'Reset password'}
)}
);
}
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.
setCreating(true)}> New group
{creating && (
)}
{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'}
toggle(g)}>{isOpen ? 'Hide' : 'Members'}
deleteGroup(g)}>Delete
{isOpen && (
{groupMembers.length === 0 && No members yet. }
{groupMembers.map(m => (
@{m.username}
removeMember(g, m.id)} title="Remove">
))}
{nonMembers.length > 0 && (
Add member:
{ addMember(g, e.target.value); e.target.value = ''; }}
style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}>
Pick a user…
{nonMembers.map(u => @{u.username}: {u.name} )}
)}
)}
);
})}
);
}
// 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
// page lives below as TokensParody and is still reachable via the hidden
// `tokens-parody` route for posterity.
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 (
Tokens
Token-metered pricing parody · You actually pay $0.00
SATIRE
setShowCalc(!showCalc)}> Cost calculator
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}
Not for sale
))}
{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 (
);
}
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
)}
Refresh
{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}
setLogsModal(null)}>
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}
{
if (navigator.clipboard) navigator.clipboard.writeText('docker compose logs -f ' + logsModal.name).catch(() => {});
}}>Copy command
setLogsModal(null)}>Close
)}
{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.image}
RUNNING
{c.healthy && healthy }
{(c.cpu || 0).toFixed(1)}%
{c.mem} MB
{c.ports}
showLogs(c)}>Logs
restartContainer(c)}>Restart
))}
)}
);
}
// ────────────────────────────────────────────────────────────────────────────
// 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 (
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
Refresh
Add node
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 ── */}
nodeLogsHint(sel)}>Logs
drainNode(sel)}>Drain
{sel.role !== "primary" && removeNode(sel)}>Remove }
)}
{adviceModal && (
setAdviceModal(null)}>
e.stopPropagation()}>
{adviceModal.title}
setAdviceModal(null)}>
{(adviceModal.lines || []).map((l, i) => (
{l}
))}
{(adviceModal.commands || []).map((c, i) => (
{c}
))}
{adviceModal.commands && adviceModal.commands.length > 0 && (
{
if (navigator.clipboard) navigator.clipboard.writeText(adviceModal.commands.join('\n')).catch(() => {});
}}>Copy commands
)}
setAdviceModal(null)}>Got it
)}
);
}
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 (
);
}
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}
navigator.clipboard.writeText(justCreated.token)}>Copy
setJustCreated(null)}>Dismiss
)}
setName(e.target.value)} style={{ flex: 1 }} />
New token
{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'}
revoke(t.id)}>Revoke
))}
);
}
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
);
}
// ────────────────────────────────────────────────────────────────────────────
// 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…
: (
)}
);
}
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 }>
);
}
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 }>
);
}
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 => (
))}
{/* ── 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 &&
Remove }
fileRef.current?.click()} disabled={uploading}>
{uploading ? `Uploading ${progress}%` : (deployed ? 'Replace' : 'Upload SDK')}
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 }>
);
}
function SettingsMsg({ msg }) {
if (!msg) return null;
return (
{msg.text}
);
}
function SField({ label, children }) {
return (
{label}
{children}
);
}
function SettingsCard({ icon, title, sub, tag, children }) {
return (
);
}
Object.assign(window, { Users, Tokens, Containers, Cluster, Settings });