dragonflight/services/web-ui/public/screens-admin.jsx
Zac Gaetano c0d1251c1f fix(tokens): add missing showCalc state — page was crashing on render
The Tokens screen referenced showCalc / setShowCalc in the Cost calculator
button and modal but never declared the state hook, so the component
threw ReferenceError on mount and rendered blank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:18:19 -04:00

1679 lines
82 KiB
JavaScript

// 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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Invite user</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Username</label>
<input className="field-input" value={form.username} autoFocus
onChange={e => setForm(p => ({...p, username: e.target.value}))}
onKeyDown={onKey} placeholder="jsmith" />
</div>
<div className="field">
<label className="field-label">Display name</label>
<input className="field-input" value={form.display_name}
onChange={e => setForm(p => ({...p, display_name: e.target.value}))}
onKeyDown={onKey} placeholder="John Smith" />
</div>
<div className="field">
<label className="field-label">Password</label>
<input className="field-input" type="password" value={form.password}
onChange={e => setForm(p => ({...p, password: e.target.value}))}
onKeyDown={onKey} placeholder="Temporary password" />
</div>
<div className="field">
<label className="field-label">Role</label>
<select className="field-input" value={form.role}
onChange={e => setForm(p => ({...p, role: e.target.value}))}
style={{ appearance: 'auto' }}>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Creating…' : 'Create user'}</button>
</div>
</div>
</div>
);
}
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 (
<div className="page">
<div className="page-header">
<h1>Users &amp; Groups</h1>
<div className="spacer" />
{tab === 'users' && (<>
<button className="btn ghost sm" onClick={exportCsv}><Icon name="download" />Export</button>
<button className="btn primary" onClick={() => setShowInvite(true)}><Icon name="plus" />Invite user</button>
</>)}
</div>
<div className="page-body">
<div className="tab-group" style={{ width: "fit-content", marginBottom: 12 }}>
<button className={tab === "users" ? "active" : ""} onClick={() => setTab("users")}>Users · {users.length}</button>
<button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups · {groups.length}</button>
<button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies</button>
</div>
{tab === 'users' && (
<div className="panel">
<div className="user-row head">
<div>User</div>
<div>Role</div>
<div>Groups</div>
<div>Created</div>
<div></div>
</div>
{users.length === 0 && (
<div style={{ padding: "32px 0", textAlign: "center", color: "var(--text-3)" }}>No users found</div>
)}
{users.map(u => (
<div key={u.id} className="user-row" style={{ position: 'relative' }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>@{u.username}</div>
</div>
</div>
<div>
<select value={u.role || 'viewer'}
onChange={e => changeRole(u, e.target.value)}
className="field-input"
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
</div>
<div style={{ position: 'relative' }}>
<button className="icon-btn" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
<Icon name="more" />
</button>
{menuFor === u.id && (
<div className="row-menu" onClick={e => e.stopPropagation()}>
<button onClick={() => { setMenuFor(null); setEditingUser(u); }}>
<Icon name="edit" size={12} />Rename
</button>
<button onClick={() => resetPassword(u)}>
<Icon name="key" size={12} />Reset password
</button>
<button className="danger" onClick={() => deleteUser(u)}>
<Icon name="trash" size={12} />Delete
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
{tab === 'policies' && (
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
<Icon name="lock" size={24} />
<div style={{ marginTop: 10, fontWeight: 500, fontSize: 14, color: 'var(--text-2)' }}>Access policies</div>
<div style={{ fontSize: 12, marginTop: 4 }}>
Per-project and per-bin permissions are coming soon. For now, role-based access<br />
(admin / editor / viewer) is enforced API-wide.
</div>
</div>
)}
</div>
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
{editingUser && (
<EditUserModal user={editingUser}
onClose={() => setEditingUser(null)}
onSaved={() => { setEditingUser(null); refreshUsers(); }}
/>
)}
{resetUser && (
<PasswordResetModal user={resetUser}
onClose={() => setResetUser(null)}
onSaved={() => setResetUser(null)}
/>
)}
</div>
);
}
function EditUserModal({ user, onClose, onSaved }) {
const [name, setName] = React.useState(user.display_name || user.name || '');
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = () => {
setSaving(true); setErr(null);
window.ZAMPP_API.fetch('/users/' + user.id, { method: 'PATCH', body: JSON.stringify({ display_name: name.trim() }) })
.then(onSaved)
.catch(e => { setSaving(false); setErr(e.message); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 400 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Rename user</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Display name</label>
<input className="field-input" autoFocus value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit(); }} />
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>username @{user.username} cannot be changed</div>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving || !name.trim()}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</div>
</div>
);
}
function 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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 400 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Reset password · @{user.username}</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
{done ? (
<div style={{ textAlign: 'center', padding: '12px 0', color: 'var(--success)', fontSize: 13 }}>
<Icon name="check" size={16} /> Password updated.
</div>
) : (<>
<div className="field">
<label className="field-label">New password</label>
<div style={{ position: 'relative' }}>
<input className="field-input" autoFocus type={show ? 'text' : 'password'}
value={pw} onChange={e => setPw(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }}
style={{ paddingRight: 36 }} />
<button className="icon-btn" style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
onClick={() => setShow(s => !s)} type="button" tabIndex={-1}>
<Icon name={show ? 'eye-off' : 'eye'} size={13} />
</button>
</div>
<div style={{ fontSize: 11, color: pw.length > 0 && pw.length < 8 ? 'var(--danger)' : 'var(--text-3)', marginTop: 4 }}>
Minimum 8 characters
</div>
</div>
<div className="field">
<label className="field-label">Confirm password</label>
<input className="field-input" type={show ? 'text' : 'password'}
value={pw2} onChange={e => setPw2(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }} />
{pw2.length > 0 && pw !== pw2 && (
<div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 4 }}>Passwords do not match</div>
)}
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</>)}
</div>
{!done && (
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving || !valid}>
{saving ? 'Saving…' : 'Reset password'}
</button>
</div>
)}
</div>
</div>
);
}
function GroupsPanel({ groups, users, onChange }) {
const [creating, setCreating] = React.useState(false);
const [newName, setNewName] = React.useState('');
const [newDesc, setNewDesc] = React.useState('');
const [expandedId, setExpandedId] = React.useState(null);
const [members, setMembers] = React.useState({}); // groupId -> [user]
const createGroup = () => {
if (!newName.trim()) return;
window.ZAMPP_API.fetch('/groups', { method: 'POST', body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() || null }) })
.then(() => { setCreating(false); setNewName(''); setNewDesc(''); onChange(); })
.catch(e => alert('Create failed: ' + e.message));
};
const deleteGroup = (g) => {
if (!confirm(`Delete group "${g.name}"?`)) return;
window.ZAMPP_API.fetch('/groups/' + g.id, { method: 'DELETE' })
.then(onChange)
.catch(e => alert('Delete failed: ' + e.message));
};
const toggle = (g) => {
if (expandedId === g.id) { setExpandedId(null); return; }
setExpandedId(g.id);
window.ZAMPP_API.fetch('/groups/' + g.id + '/members')
.then(list => setMembers(m => ({ ...m, [g.id]: list || [] })))
.catch(() => setMembers(m => ({ ...m, [g.id]: [] })));
};
const addMember = (g, userId) => {
if (!userId) return;
window.ZAMPP_API.fetch('/groups/' + g.id + '/members', { method: 'POST', body: JSON.stringify({ user_id: userId }) })
.then(() => {
window.ZAMPP_API.fetch('/groups/' + g.id + '/members')
.then(list => setMembers(m => ({ ...m, [g.id]: list || [] })));
onChange();
})
.catch(e => alert('Add failed: ' + e.message));
};
const removeMember = (g, uid) => {
window.ZAMPP_API.fetch('/groups/' + g.id + '/members/' + uid, { method: 'DELETE' })
.then(() => {
setMembers(m => ({ ...m, [g.id]: (m[g.id] || []).filter(u => u.id !== uid) }));
onChange();
})
.catch(e => alert('Remove failed: ' + e.message));
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
<div style={{ flex: 1, fontSize: 12, color: 'var(--text-3)' }}>
Groups let you bundle users for project access. Memberships are checked when role-based access is enforced.
</div>
<button className="btn primary sm" onClick={() => setCreating(true)}><Icon name="plus" size={11} />New group</button>
</div>
{creating && (
<div className="panel" style={{ padding: 12, marginBottom: 12, display: 'grid', gridTemplateColumns: '1fr 2fr auto auto', gap: 8, alignItems: 'end' }}>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">Group name</label>
<input className="field-input" autoFocus value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createGroup(); }} placeholder="broadcasters" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">Description (optional)</label>
<input className="field-input" value={newDesc} onChange={e => setNewDesc(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createGroup(); }} placeholder="On-air operators" />
</div>
<button className="btn primary sm" onClick={createGroup} disabled={!newName.trim()}>Create</button>
<button className="btn ghost sm" onClick={() => { setCreating(false); setNewName(''); setNewDesc(''); }}>Cancel</button>
</div>
)}
<div className="panel">
{groups.length === 0 && !creating && (
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
No groups yet click <em>New group</em> above to create one.
</div>
)}
{groups.map(g => {
const isOpen = expandedId === g.id;
const groupMembers = members[g.id] || [];
const nonMembers = users.filter(u => !groupMembers.some(m => m.id === u.id));
return (
<div key={g.id} style={{ borderBottom: '1px solid var(--border)' }}>
<div style={{ padding: '12px 16px', display: 'grid', gridTemplateColumns: '1.6fr 2fr 90px 80px', alignItems: 'center', gap: 12 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{g.name}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{g.id.slice(0, 8)}</div>
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{g.description || <span style={{ fontStyle: 'italic' }}>no description</span>}</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{g.member_count || 0} member{g.member_count === 1 ? '' : 's'}</div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
<button className="btn ghost sm" onClick={() => toggle(g)}>{isOpen ? 'Hide' : 'Members'}</button>
<button className="btn ghost sm danger" onClick={() => deleteGroup(g)}>Delete</button>
</div>
</div>
{isOpen && (
<div style={{ padding: '0 16px 16px 16px', background: 'var(--bg-2)' }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
{groupMembers.length === 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>No members yet.</span>}
{groupMembers.map(m => (
<span key={m.id} className="badge outline" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
@{m.username}
<button className="icon-btn" style={{ width: 16, height: 16, padding: 0 }} onClick={() => removeMember(g, m.id)} title="Remove">
<Icon name="x" size={9} />
</button>
</span>
))}
</div>
{nonMembers.length > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Add member:</span>
<select className="field-input" defaultValue=""
onChange={e => { addMember(g, e.target.value); e.target.value = ''; }}
style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}>
<option value="" disabled> Pick a user </option>
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username} {u.name}</option>)}
</select>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function Tokens() {
const [burned, setBurned] = React.useState(14340);
const [rate, setRate] = React.useState(2.4);
const [showCalc, setShowCalc] = React.useState(false);
React.useEffect(() => {
const i = setInterval(() => {
setBurned(b => b + Math.floor(Math.random() * 8) + 1);
setRate(r => Math.max(0.8, Math.min(8, r + (Math.random() - 0.5) * 0.4)));
}, 800);
return () => clearInterval(i);
}, []);
const burnSpark = React.useMemo(() => Array.from({ length: 40 }, (_, i) => 100 + i * 8 + Math.sin(i * 0.7) * 12), []);
const competitorSpark = React.useMemo(() => Array.from({ length: 40 }, (_, i) => 200 + i * 22 + Math.cos(i * 0.5) * 30), []);
const yourCostSpark = React.useMemo(() => Array.from({ length: 40 }, () => 1), []);
const [events, setEvents] = React.useState([
{ t: "21:14:02", action: "preview thumbnail generated", cost: 4 },
{ t: "21:14:01", action: "user clicked play", cost: 12 },
{ t: "21:13:58", action: "API health check", cost: 8 },
{ t: "21:13:54", action: "asset metadata read", cost: 2 },
{ t: "21:13:51", action: "session token refreshed", cost: 18 },
{ t: "21:13:47", action: "scrubbed timeline 1 frame", cost: 6 },
{ t: "21:13:42", action: "took a deep breath near the API", cost: 24 },
]);
React.useEffect(() => {
const actions = [
"preview thumbnail generated", "user clicked play", "API health check",
"scrubbed timeline 1 frame", "asset metadata read", "session token refreshed",
"checked job queue", "rendered a tooltip", "loaded sidebar icon",
"blinked", "made eye contact with the cluster", "opened a modal (twice)",
"asset list pagination request", "thought about a comment", "moved cursor near 'Save'",
];
const i = setInterval(() => {
const now = new Date();
const t = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
const a = actions[Math.floor(Math.random() * actions.length)];
const c = Math.floor(Math.random() * 28) + 1;
setEvents(ev => [{ t, action: a, cost: c }, ...ev].slice(0, 12));
}, 1600);
return () => clearInterval(i);
}, []);
const tiers = [
{ name: "Starter", desc: "For \"evaluation only\" — definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
{ name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
{ name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" },
];
return (
<div className="page">
<div className="page-header">
<h1>Tokens</h1>
<span className="subtitle">Token-metered pricing parody · You actually pay <strong style={{ color: "var(--success)" }}>$0.00</strong></span>
<div className="spacer" />
<span className="badge warning"><Icon name="alert" size={10} /> SATIRE</span>
<button className="btn ghost sm" onClick={() => setShowCalc(!showCalc)}><Icon name="sliders" />Cost calculator</button>
</div>
<div className="page-body">
<div style={{ textAlign: 'center', padding: '8px 0 36px' }}>
<h2 style={{ fontSize: 30, fontWeight: 700, letterSpacing: '-0.02em', lineHeight: 1.3, margin: 0 }}>
<span style={{ textDecoration: 'line-through', color: 'var(--text-3)', fontWeight: 500 }}>Per-seat</span>
{' · '}
<span style={{ textDecoration: 'line-through', color: 'var(--text-3)', fontWeight: 500 }}>Per-stream</span>
{' · '}
<span style={{ textDecoration: 'line-through', color: 'var(--text-3)', fontWeight: 500 }}>Per-month</span>
<br />
<span style={{ fontSize: 52, fontWeight: 800, color: 'var(--accent-text)', letterSpacing: '-0.03em' }}>Per Token.</span>
</h2>
</div>
<div className="token-hero">
<div className="token-burn-card">
<div className="token-card-label">TOKENS BURNED THIS SESSION</div>
<div className="token-counter">
<span className="token-flame">🔥</span>
<span className="token-big mono">{burned.toLocaleString()}</span>
</div>
<div className="token-rate">
<span className="mono" style={{ color: "var(--danger)" }}> {rate.toFixed(1)}k/sec</span>
<span style={{ color: "var(--text-3)", marginLeft: 10 }}>burning since you logged in</span>
</div>
<div style={{ marginTop: 12 }}>
<Sparkline data={burnSpark} color="#FF5B5B" />
</div>
</div>
<div className="token-actual-card">
<div className="token-card-label">WHAT YOU ACTUALLY PAY</div>
<div className="token-actual-amount">
<span style={{ fontSize: 48, fontWeight: 700, letterSpacing: "-0.04em" }}>$0</span>
<span style={{ fontSize: 18, color: "var(--text-3)" }}>.00</span>
</div>
<div style={{ fontSize: 12, color: "var(--text-3)", lineHeight: 1.5 }}>
Dragonflight is self-hosted. The tokens above are imaginary.<br />
Imagine them as a stress test for your sanity.
</div>
<div style={{ marginTop: 12 }}>
<Sparkline data={yourCostSpark} color="#2DD4A8" fill={false} />
</div>
</div>
</div>
<div className="token-comparison">
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN DRAGONFLIGHT vs. THE OTHER GUYS</div>
<div className="token-compare-chart">
<ChartLine
series={[
{ label: "AMPP-style competitor", data: competitorSpark, color: "#FF5B5B" },
{ label: "Dragonflight (yours)", data: yourCostSpark.map((_, i) => i < 20 ? 1 : 1), color: "#2DD4A8" },
]}
/>
<div className="token-compare-legend">
<div><span className="dot" style={{ background: "#FF5B5B" }} />Competitor: $1,247/hr and rising</div>
<div><span className="dot" style={{ background: "#2DD4A8" }} />Dragonflight: $0.00/hr forever</div>
</div>
</div>
</div>
<div className="token-grid">
<div>
<div className="token-card-label" style={{ marginBottom: 8 }}>LIVE BILLING EVENTS</div>
<div className="panel">
{events.map((e, i) => (
<div key={i} className={`token-event ${i === 0 ? "fresh" : ""}`}>
<span className="mono" style={{ color: "var(--text-3)", fontSize: 11 }}>{e.t}</span>
<span style={{ flex: 1, fontSize: 12.5 }}>{e.action}</span>
<span className="mono" style={{ color: "var(--danger)", fontWeight: 600 }}>+{e.cost} tk</span>
</div>
))}
</div>
</div>
<div>
<div className="token-card-label" style={{ marginBottom: 8 }}>PRICING TIERS WE DIDN'T COPY</div>
<div className="token-tiers">
{tiers.map(t => (
<div key={t.name} className={`token-tier ${t.popular ? "popular" : ""}`}>
{t.popular && <span className="token-tier-badge">MOST PAIN</span>}
<div className="token-tier-name" style={{ color: t.color }}>{t.name}</div>
<div className="token-tier-desc">{t.desc}</div>
<div className="token-tier-price">
<span style={{ fontSize: 26, fontWeight: 700, letterSpacing: "-0.02em" }}>{t.price}</span>
<span style={{ fontSize: 12, color: "var(--text-3)", marginLeft: 4 }}>{t.per}</span>
</div>
<div className="token-tier-tokens mono">{t.tokens}</div>
<button className="btn subtle sm" disabled style={{ width: "100%", marginTop: 8 }}>Not for sale</button>
</div>
))}
</div>
</div>
</div>
{showCalc && <CostCalculator onClose={() => setShowCalc(false)} />}
<div className="token-footnote">
<Icon name="alert" size={14} />
<div>
<strong>Disclaimer:</strong> No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform
is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical
and protected as commentary. If you came here looking for actual API tokens, that page no longer exists service
credentials are managed through the cluster's own JWT issuer.
</div>
</div>
</div>
</div>
);
}
function ChartLine({ series }) {
const w = 600, h = 140;
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: "100%", height: 140 }}>
<defs>
<pattern id="cgrid" width="60" height="28" patternUnits="userSpaceOnUse">
<path d="M 60 0 L 0 0 0 28" fill="none" stroke="rgba(255,255,255,0.04)" strokeWidth="1" />
</pattern>
</defs>
<rect width={w} height={h} fill="url(#cgrid)" />
{series.map((s, si) => {
const max = Math.max(...series.flatMap(x => x.data), 1);
const pts = s.data.map((d, i) => {
const x = (i / (s.data.length - 1)) * w;
const y = h - (d / max) * (h - 10) - 4;
return `${x},${y}`;
}).join(" ");
const area = `0,${h} ${pts} ${w},${h}`;
return (
<g key={si}>
<polygon points={area} fill={s.color} opacity="0.1" />
<polyline points={pts} fill="none" stroke={s.color} strokeWidth="2" />
<circle cx={w} cy={h - (s.data[s.data.length - 1] / max) * (h - 10) - 4} r="4" fill={s.color} />
<circle cx={w} cy={h - (s.data[s.data.length - 1] / max) * (h - 10) - 4} r="8" fill={s.color} opacity="0.3">
<animate attributeName="r" values="4;14;4" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.6;0;0.6" dur="2s" repeatCount="indefinite" />
</circle>
</g>
);
})}
</svg>
);
}
function CostCalculator({ onClose }) {
const [users, setUsers] = React.useState(12);
const [assets, setAssets] = React.useState(500);
const [clicks, setClicks] = React.useState(2000);
const cost = users * 240 + assets * 8 + clicks * 0.12;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 520 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Token Cost Calculator</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>What it would cost on AMPP-style pricing</div>
</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<CalcSlider label="Users" value={users} onChange={setUsers} min={1} max={100} unit=" people" />
<CalcSlider label="Assets in library" value={assets} onChange={setAssets} min={50} max={10000} step={50} unit="" />
<CalcSlider label="UI clicks per day" value={clicks} onChange={setClicks} min={100} max={20000} step={100} unit="" />
<div style={{ background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, marginTop: 8 }}>
<div style={{ fontSize: 11, color: "var(--text-3)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>You would be paying</div>
<div style={{ fontSize: 36, fontWeight: 700, color: "var(--danger)", letterSpacing: "-0.02em", marginTop: 4 }}>
${cost.toLocaleString("en-US", { maximumFractionDigits: 0 })}<span style={{ fontSize: 14, color: "var(--text-3)", fontWeight: 400, marginLeft: 4 }}>/ month</span>
</div>
<div style={{ marginTop: 8, padding: 10, background: "var(--success-soft)", borderRadius: 6, fontSize: 12.5, color: "var(--success)" }}>
<strong>Your actual Dragonflight cost:</strong> $0.00. You're welcome.
</div>
</div>
</div>
</div>
</div>
);
}
function CalcSlider({ label, value, onChange, min, max, step = 1, unit }) {
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6, fontSize: 12 }}>
<span style={{ color: "var(--text-2)" }}>{label}</span>
<span className="mono" style={{ color: "var(--text-1)", fontWeight: 600 }}>{value.toLocaleString()}{unit}</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={e => onChange(Number(e.target.value))}
style={{ width: "100%", accentColor: "var(--accent)" }}
/>
</div>
);
}
function Containers() {
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 (
<div className="page">
<div className="page-header">
<h1>Containers</h1>
<span className="subtitle">Docker Compose services across the cluster</span>
<div className="spacer" />
{containers !== null && containers.length > 0 && (
<div className="status-pip">
<span className="dot" />
<span>{running} / {containers.length} running</span>
</div>
)}
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
</div>
{restartFlash && (
<div style={{
position: 'fixed', bottom: 20, right: 20, zIndex: 200,
background: restartFlash.status === 'ok' ? 'var(--success-soft)'
: restartFlash.status === 'fail' ? 'var(--danger-soft)'
: 'var(--bg-2)',
color: restartFlash.status === 'ok' ? 'var(--success)'
: restartFlash.status === 'fail' ? 'var(--danger)' : 'var(--text-2)',
border: '1px solid', borderColor: restartFlash.status === 'ok' ? 'var(--success)'
: restartFlash.status === 'fail' ? 'var(--danger)' : 'var(--border)',
borderRadius: 6, padding: '10px 14px', fontSize: 12.5, maxWidth: 320,
}}>
{restartFlash.status === 'pending' && `Restarting ${restartFlash.name}`}
{restartFlash.status === 'ok' && `${restartFlash.name} restarted.`}
{restartFlash.status === 'fail' && `${restartFlash.name}: ${restartFlash.error}`}
</div>
)}
{logsModal && (
<div className="modal-backdrop" onClick={() => setLogsModal(null)}>
<div className="modal" style={{ width: 540 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Logs · {logsModal.name}</div>
<button className="icon-btn" onClick={() => setLogsModal(null)}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}>
Live log streaming over the websocket isn't wired yet. SSH to the host that runs this container and tail the logs directly:
</div>
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 10, borderRadius: 5, fontSize: 11.5, overflowX: 'auto' }}>
docker compose logs -f {logsModal.name}
</code>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 10 }}>
Or grab the last 200 lines:&nbsp;
<span className="mono">docker logs --tail 200 {logsModal.name}</span>
</div>
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={() => {
if (navigator.clipboard) navigator.clipboard.writeText('docker compose logs -f ' + logsModal.name).catch(() => {});
}}>Copy command</button>
<button className="btn primary sm" onClick={() => setLogsModal(null)}>Close</button>
</div>
</div>
</div>
)}
<div className="page-body">
{containers === null && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading…</div>
)}
{containers !== null && containers.length === 0 && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🐳</div>
<div style={{ fontWeight: 500, fontSize: 14 }}>No containers returned</div>
<div style={{ fontSize: 12, marginTop: 6 }}>Confirm <code>/var/run/docker.sock</code> is mounted in the mam-api container</div>
</div>
)}
{containers !== null && containers.length > 0 && (
<div className="panel">
<div className="container-row head">
<div>Container</div>
<div>Image</div>
<div>State</div>
<div>CPU</div>
<div>Memory</div>
<div>Ports</div>
<div></div>
</div>
{containers.map(c => (
<div key={c.id || c.name} className="container-row">
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{c.name}</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>up {c.uptime}</div>
</div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-2)" }}>{c.image}</div>
<div>
<span className="badge success"><StatusDot status="online" /> RUNNING</span>
{c.healthy && <span style={{ fontSize: 10.5, color: "var(--success)", marginLeft: 6 }}>healthy</span>}
</div>
<div className="mono" style={{ fontSize: 11.5 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 40, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${Math.min((c.cpu || 0) * 4, 100)}%`, height: "100%", background: "var(--accent)" }} />
</div>
<span>{(c.cpu || 0).toFixed(1)}%</span>
</div>
</div>
<div className="mono" style={{ fontSize: 11.5 }}>{c.mem} MB</div>
<div className="mono" style={{ fontSize: 10.5, color: "var(--text-3)" }}>{c.ports}</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn ghost sm" onClick={() => showLogs(c)}>Logs</button>
<button className="btn ghost sm" onClick={() => restartContainer(c)}>Restart</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
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 (
<div className="page">
<div className="page-header">
<h1>Cluster</h1>
<div className="spacer" />
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>No cluster nodes available</div>
</div>
</div>
);
}
const primary = NODES.find(n => n.role === 'primary') || NODES[0];
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 (
<div className="page">
<div className="page-header">
<h1>Cluster</h1>
<span className="subtitle">{NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online</span>
<div className="spacer" />
<div className="status-pip"><span className="dot" /><span>Live</span></div>
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={addNode}><Icon name="plus" />Add node</button>
</div>
<div className="page-body">
<div className="stat-row" style={{ padding: 0, marginBottom: 16 }}>
<div className="stat-card">
<div className="label"><Icon name="cluster" size={12} />Nodes</div>
<div className="value">{NODES.length}</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="cpu" size={12} />Avg CPU</div>
<div className="value">{Math.round(NODES.reduce((a, n) => a + (n.cpu || 0), 0) / NODES.length)} <span style={{ fontSize: 14, color: "var(--text-3)" }}>%</span></div>
</div>
<div className="stat-card">
<div className="label"><Icon name="gpu" size={12} />GPUs</div>
<div className="value">{NODES.reduce((a, n) => a + n.gpus.length, 0)}</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="hdd" size={12} />Avg Memory</div>
<div className="value">{Math.round(NODES.reduce((a, n) => a + (n.mem || 0), 0) / NODES.length)} <span style={{ fontSize: 14, color: "var(--text-3)" }}>GB</span></div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: 16, alignItems: "start" }}>
<div className="cluster-canvas">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", borderBottom: "1px solid var(--border)" }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>Topology</span>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{NODES.length} node{NODES.length === 1 ? '' : 's'}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ display: "block", width: "100%", height: "auto" }}>
<defs>
<radialGradient id="nodeGlow">
<stop offset="0%" stopColor="rgba(91,124,250,0.3)" />
<stop offset="100%" stopColor="rgba(91,124,250,0)" />
</radialGradient>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.025)" strokeWidth="1" />
</pattern>
</defs>
<rect width={W} height={H} fill="url(#grid)" />
{edges.map((e, i) => {
const x1 = e.from.x * W, y1 = e.from.y * H;
const x2 = e.to.x * W, y2 = e.to.y * H;
return (
<g key={i}>
<line x1={x1} y1={y1} x2={x2} y2={y2}
stroke={e.alive ? "var(--accent)" : "var(--text-4)"}
strokeWidth="1"
strokeDasharray={e.alive ? "0" : "4 3"}
opacity={e.alive ? 0.5 : 0.25}
/>
{e.alive && (
<circle r="3" fill="var(--accent)">
<animateMotion dur={`${2 + i * 0.4}s`} repeatCount="indefinite"
path={`M ${x1} ${y1} L ${x2} ${y2}`} />
</circle>
)}
</g>
);
})}
{NODES.map(n => {
const cx = n.x * W, cy = n.y * H;
const isSelected = sel && sel.id === n.id;
const color = n.status === "online" ? "var(--success)" : "var(--text-4)";
return (
<g key={n.id} transform={`translate(${cx}, ${cy})`}
style={{ cursor: "pointer" }}
onMouseEnter={() => setHovered(n.id)}
onMouseLeave={() => setHovered(null)}
onClick={() => setSelected(n)}>
{n.status === "online" && (
<circle r="44" fill="url(#nodeGlow)">
<animate attributeName="r" values="34;48;34" dur="3s" repeatCount="indefinite" />
</circle>
)}
<circle r={isSelected ? 26 : 22} fill="var(--bg-2)" stroke={isSelected ? "var(--accent)" : "var(--border-stronger)"} strokeWidth={isSelected ? 2 : 1} />
<circle r="6" cx="-13" cy="-13" fill={color} />
{n.role === "primary" && <path d="M -4 -2 L 0 2 L 4 -2 L 0 -6 Z" fill="var(--accent)" stroke="none" />}
{n.role !== "primary" && <text textAnchor="middle" y="3" fill="var(--text-2)" fontSize="10" fontFamily="var(--font-mono)">{n.role[0].toUpperCase()}</text>}
<text textAnchor="middle" y="40" fill={isSelected ? "var(--text-1)" : "var(--text-2)"} fontSize="11" fontWeight={isSelected ? 600 : 500}>{n.id}</text>
<text textAnchor="middle" y="54" fill="var(--text-3)" fontSize="10" fontFamily="var(--font-mono)">{n.ip}</text>
</g>
);
})}
</svg>
</div>
{sel && (
<div className="panel">
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--border)", display: "flex", alignItems: "center", gap: 8 }}>
<StatusDot status={sel.status} />
<span style={{ fontWeight: 600, fontSize: 13 }}>{sel.id}</span>
<span className={`badge ${sel.role === "primary" ? "accent" : "neutral"}`}>{sel.role}</span>
</div>
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 10 }}>
<DetailRow k="Status" v={<span style={{ color: sel.status === "online" ? "var(--success)" : "var(--text-3)" }}>{sel.status}</span>} />
<DetailRow k="IP" v={sel.ip} mono />
<DetailRow k="Version" v={sel.version} mono />
<DetailRow k="Uptime" v={sel.uptime} mono />
<DetailRow k="CPU" v={
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 80, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${sel.cpu}%`, height: "100%", background: "var(--accent)" }} />
</div>
<span className="mono">{sel.cpu}%</span>
</div>
} />
{sel.memTotal > 0 && (
<DetailRow k="Memory" v={
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 80, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${(sel.mem / sel.memTotal) * 100}%`, height: "100%", background: "var(--purple)" }} />
</div>
<span className="mono">{sel.mem} / {sel.memTotal} GB</span>
</div>
} />
)}
{sel.gpus.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>GPUs ({sel.gpus.length})</div>
{sel.gpus.map((g, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4, marginBottom: 4, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} style={{ color: "var(--text-3)" }} />
<span>{g}</span>
</div>
))}
</div>
)}
{sel.devices && sel.devices.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6 }}>Capture devices</div>
{sel.devices.map((d, i) => (
<div key={i} className="mono" style={{ fontSize: 11.5, padding: "5px 8px", background: "var(--bg-2)", borderRadius: 4 }}>
<Icon name="video" size={11} style={{ color: "var(--text-3)", marginRight: 6 }} />{d}
</div>
))}
</div>
)}
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
<button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
<button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>
{sel.role !== "primary" && <button className="btn danger sm" onClick={() => removeNode(sel)}>Remove</button>}
</div>
</div>
</div>
)}
</div>
</div>
{adviceModal && (
<div className="modal-backdrop" onClick={() => setAdviceModal(null)}>
<div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>{adviceModal.title}</div>
<button className="icon-btn" onClick={() => setAdviceModal(null)}><Icon name="x" /></button>
</div>
<div className="modal-body">
{(adviceModal.lines || []).map((l, i) => (
<div key={i} style={{ fontSize: 12.5, color: 'var(--text-2)', marginBottom: 6, lineHeight: 1.55 }}>{l}</div>
))}
{(adviceModal.commands || []).map((c, i) => (
<code key={i} className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 10, borderRadius: 5, fontSize: 11.5, marginTop: 8, overflowX: 'auto' }}>{c}</code>
))}
</div>
<div className="modal-foot">
{adviceModal.commands && adviceModal.commands.length > 0 && (
<button className="btn ghost sm" onClick={() => {
if (navigator.clipboard) navigator.clipboard.writeText(adviceModal.commands.join('\n')).catch(() => {});
}}>Copy commands</button>
)}
<button className="btn primary sm" onClick={() => setAdviceModal(null)}>Got it</button>
</div>
</div>
</div>
)}
</div>
);
}
function DetailRow({ k, v, mono }) {
return (
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}>
<span style={{ color: "var(--text-3)" }}>{k}</span>
<span className={mono ? "mono" : ""} style={{ fontSize: mono ? 11.5 : 12 }}>{v}</span>
</div>
);
}
function Settings() {
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 (
<div className="page">
<div className="page-header">
<h1>Settings</h1>
<span className="subtitle">System configuration · changes apply without restart</span>
</div>
<div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 24, alignItems: 'start' }}>
<nav className="settings-nav">
{SECTIONS.map(s => (
<a key={s.id}
className={`settings-nav-item ${section === s.id ? 'active' : ''}`}
onClick={() => setSection(s.id)}
style={{ cursor: 'pointer' }}>
<Icon name={s.icon} size={14} />{s.label}
</a>
))}
</nav>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{section === 'storage' && <S3SettingsCard />}
{section === 'proxy' && <GpuSettingsCard />}
{section === 'growing' && <GrowingSettingsCard />}
{section === 'sdk' && <SdkSettingsCard />}
{section === 'sdi' && <SdiSettingsCard />}
</div>
</div>
</div>
</div>
);
}
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 (
<SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"
tag={secretExists ? <span className="badge success">connected</span> : <span className="badge warning">not configured</span>}>
{loading ? <div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div> : (<>
<SField label="Endpoint URL">
<input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
</SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Region"><input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField>
<SField label="Bucket"><input className="field-input mono" value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
</div>
<SField label="Access key ID"><input className="field-input mono" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /></SField>
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} /></SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
<button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div>
</>)}
</SettingsCard>
);
}
function GpuSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/transcoding').then(setCfg).catch(() => setCfg({}));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
.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 <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"><div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const gpuEnabled = cfg.gpu_transcode_enabled === 'true' || cfg.gpu_transcode_enabled === true;
return (
<SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"
tag={gpuEnabled ? <span className="badge success">GPU mode</span> : <span className="badge neutral">CPU mode</span>}>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 4 }}>
These settings drive the proxy worker for <strong style={{ color: 'var(--text-2)' }}>every</strong> ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.
</div>
<SField label="Hardware acceleration">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available falls back to CPU on missing hardware</span>
</label>
</SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label={gpuEnabled ? 'GPU codec' : 'CPU codec'}>
<select className="field-input" value={cfg.gpu_codec || 'h264_nvenc'} onChange={e => set('gpu_codec', e.target.value)} style={{ appearance: 'auto' }}>
{gpuEnabled ? (<>
<option value="h264_nvenc">h264_nvenc (NVIDIA)</option>
<option value="hevc_nvenc">hevc_nvenc (NVIDIA HEVC)</option>
<option value="h264_vaapi">h264_vaapi (Intel/AMD)</option>
<option value="hevc_vaapi">hevc_vaapi (Intel/AMD HEVC)</option>
</>) : (<>
<option value="libx264">libx264 (H.264, recommended)</option>
<option value="libx265">libx265 (HEVC, slower)</option>
</>)}
</select>
</SField>
<SField label="Preset">
<select className="field-input" value={cfg.gpu_preset || (gpuEnabled ? 'p4' : 'fast')} onChange={e => set('gpu_preset', e.target.value)} style={{ appearance: 'auto' }}>
{gpuEnabled
? ['p1','p2','p3','p4','p5','p6','p7'].map(p => <option key={p} value={p}>{p}</option>)
: ['ultrafast','superfast','veryfast','faster','fast','medium','slow','slower'].map(p => <option key={p} value={p}>{p}</option>)}
</select>
</SField>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Target bitrate (Mbps)">
<input className="field-input mono" type="number" value={cfg.gpu_bitrate_mbps || ''} onChange={e => set('gpu_bitrate_mbps', e.target.value)} placeholder="10" />
</SField>
<SField label="Rate control">
<select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
<option value="cbr">CBR constant bitrate</option>
<option value="vbr">VBR variable bitrate</option>
<option value="cqp">CQP / CRF constant quality</option>
</select>
</SField>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Audio codec">
<select className="field-input" value={cfg.gpu_audio_codec || 'aac'} onChange={e => set('gpu_audio_codec', e.target.value)} style={{ appearance: 'auto' }}>
<option value="aac">aac</option>
<option value="opus">opus</option>
<option value="mp3">mp3</option>
</select>
</SField>
<SField label="Audio bitrate (kbps)">
<input className="field-input mono" type="number" value={cfg.gpu_audio_bitrate_kbps || ''} onChange={e => set('gpu_audio_bitrate_kbps', e.target.value)} placeholder="192" />
</SField>
</div>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</SettingsCard>
);
}
function GrowingSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({
growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8',
}));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
return (
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<SField label="Enable growing-file capture">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Capture writes to the local SMB share first; Premier can edit while it's still growing.</span>
</label>
</SField>
<SField label="Container mount path">
<input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />
</SField>
<SField label="SMB share URL (for editors)">
<input className="field-input mono" value={cfg.growing_smb_url || ''} onChange={e => set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" />
</SField>
<SField label="Promote-to-S3 idle threshold (seconds)">
<input className="field-input mono" type="number" value={cfg.growing_promote_after_seconds || ''} onChange={e => set('growing_promote_after_seconds', e.target.value)} placeholder="8" />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving' : 'Save'}</button>
</div>
</SettingsCard>
);
}
function SdiSettingsCard() {
return (
<SettingsCard icon="video" title="SDI capture" sub="DeckLink device routing and defaults"
tag={<span className="badge neutral">per-recorder</span>}>
<div style={{ color: 'var(--text-3)', fontSize: 12.5, lineHeight: 1.6 }}>
SDI settings are configured per-recorder. Use{' '}
<strong style={{ color: 'var(--text-2)' }}>Ingest → Recorders → New recorder</strong>{' '}
to pick the DeckLink port, codec, and audio routing.
</div>
<div style={{ marginTop: 12 }}>
<a className="btn ghost sm" href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('dragonflight-navigate', { detail: 'capture' })); }}>
<Icon name="video" />Open Capture dashboard
</a>
</div>
</SettingsCard>
);
}
// ────────────────────────────────────────────────────────────────────────────
// Capture SDK deployment — Blackmagic / AJA / Deltacast
// ────────────────────────────────────────────────────────────────────────────
const SDK_VENDORS = [
{
id: 'blackmagic',
name: 'Blackmagic DeckLink',
sub: 'DeckLink SDK 16.x required for SDI capture via DeckLink cards',
expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',
docs: 'https://www.blackmagicdesign.com/developer/product/capture',
buildHint: 'docker compose build --no-cache capture',
status: 'wired',
},
{
id: 'aja',
name: 'AJA NTV2',
sub: 'NTV2 SDK — for Kona / Io / U-Tap / T-Tap cards',
expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',
docs: 'https://sdksupport.aja.com/',
buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
status: 'staging-only',
},
{
id: 'deltacast',
name: 'Deltacast VideoMaster',
sub: 'VideoMasterHD SDK — for FLEX / DELTA-h4k2 / etc.',
expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',
docs: 'https://www.deltacast.tv/products/sdk',
buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
status: 'staging-only',
},
];
function SdkSettingsCard() {
const [statuses, setStatuses] = React.useState(null);
const [msg, setMsg] = React.useState(null);
const load = React.useCallback(() => {
window.ZAMPP_API.fetch('/sdk').then(setStatuses).catch(() => setStatuses({}));
}, []);
React.useEffect(() => { load(); }, [load]);
return (
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
tag={<span className="badge neutral">{SDK_VENDORS.length} vendors</span>}>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 4 }}>
Each SDK archive should be a <strong style={{ color: 'var(--text-2)' }}>.zip</strong> or <strong style={{ color: 'var(--text-2)' }}>.tar.gz</strong> containing the vendor's Linux SDK contents. After uploading, rebuild the capture container on the host with a DeckLink/AJA/Deltacast card. The SDK files are staged under <code className="mono" style={{ fontSize: 11.5 }}>/sdk/&lt;vendor&gt;/</code> inside mam-api.
</div>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{SDK_VENDORS.map(v => (
<SdkVendorRow
key={v.id}
vendor={v}
status={(statuses && statuses[v.id]) || null}
onDone={(text, ok = true) => { setMsg({ ok, text }); load(); }}
/>
))}
</div>
</SettingsCard>
);
}
function SdkVendorRow({ vendor, status, onDone }) {
const fileRef = React.useRef(null);
const [uploading, setUploading] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const deployed = status && status.file_count > 0;
const lastUpload = status?.uploaded_at
? new Date(status.uploaded_at).toLocaleString()
: null;
const handleFile = async (file) => {
if (!file) return;
setUploading(true); setProgress(0);
const fd = new FormData();
fd.append('archive', file);
// Use XHR so we can report progress to the user — fetch's stream API is fiddly.
await new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/v1/sdk/' + vendor.id);
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
setUploading(false); setProgress(0);
if (xhr.status >= 200 && xhr.status < 300) {
onDone(vendor.name + ': SDK staged.', true);
} else {
let txt = xhr.responseText;
try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
onDone(vendor.name + ': upload failed — ' + txt, false);
}
resolve();
};
xhr.onerror = () => {
setUploading(false); setProgress(0);
onDone(vendor.name + ': network error', false);
resolve();
};
xhr.send(fd);
});
};
const clear = () => {
if (!confirm('Remove staged ' + vendor.name + ' SDK files?')) return;
window.ZAMPP_API.fetch('/sdk/' + vendor.id, { method: 'DELETE' })
.then(() => onDone(vendor.name + ': cleared.', true))
.catch(e => onDone(vendor.name + ': ' + e.message, false));
};
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: 12, background: 'var(--bg-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<strong style={{ fontSize: 13 }}>{vendor.name}</strong>
{deployed
? <span className="badge success">deployed · {status.file_count} files</span>
: <span className="badge neutral">not deployed</span>}
{vendor.status === 'staging-only' && <span className="badge warning" title={vendor.buildHint}>build pipeline pending</span>}
<div style={{ flex: 1 }} />
{deployed && <button className="btn ghost sm" onClick={clear}>Remove</button>}
<button className="btn primary sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? `Uploading ${progress}%` : (deployed ? 'Replace' : 'Upload SDK')}
</button>
<input ref={fileRef} type="file" accept=".zip,.tar.gz,.tgz,.tar"
style={{ display: 'none' }}
onChange={e => handleFile(e.target.files?.[0])} />
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', lineHeight: 1.55 }}>
{vendor.sub}<br />
<span className="mono" style={{ fontSize: 11 }}>expects: {vendor.expect}</span>
{lastUpload && <><br /><span style={{ color: 'var(--text-3)' }}>uploaded: {lastUpload}</span></>}
{deployed && <><br /><span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>on host: rebuild with {vendor.buildHint}</span></>}
</div>
</div>
);
}
function AmppSettingsCard() {
const [cfg, setCfg] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const [tokenExists, setTokenExists] = React.useState(false);
React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/ampp').then(d => {
setCfg({ ampp_base_url: d.ampp_base_url || '', ampp_token: '' });
setTokenExists(!!d.ampp_token_exists);
}).catch(() => setCfg({ ampp_base_url: '', ampp_token: '' }));
}, []);
const save = () => {
setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/ampp', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); if (cfg.ampp_token) setTokenExists(true); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
};
const test = () => {
setTesting(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/ampp/test', { method: 'POST', body: JSON.stringify(cfg) })
.then(r => { setTesting(false); setMsg({ ok: true, text: r.message || 'Connection OK' }); })
.catch(e => { setTesting(false); setMsg({ ok: false, text: e.message }); });
};
if (!cfg) return <SettingsCard icon="link" title="AMPP integration" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>;
return (
<SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"
tag={tokenExists ? <span className="badge success">connected</span> : <span className="badge neutral">not configured</span>}>
<SField label="AMPP base URL">
<input className="field-input mono" value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
</SField>
<SField label="API token">
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div>
</SettingsCard>
);
}
function SettingsMsg({ msg }) {
if (!msg) return null;
return (
<div style={{ fontSize: 12, padding: '6px 10px', borderRadius: 5, border: '1px solid',
background: msg.ok ? 'var(--success-soft)' : 'var(--danger-soft)',
borderColor: msg.ok ? 'var(--success)' : 'var(--danger)',
color: msg.ok ? 'var(--success)' : 'var(--danger)' }}>
{msg.text}
</div>
);
}
function SField({ label, children }) {
return (
<div className="field">
<label className="field-label">{label}</label>
{children}
</div>
);
}
function SettingsCard({ icon, title, sub, tag, children }) {
return (
<div className="settings-card">
<div className="settings-card-head">
<div className="settings-card-icon"><Icon name={icon} size={16} /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>{sub}</div>
</div>
{tag}
</div>
<div className="settings-card-body">{children}</div>
</div>
);
}
Object.assign(window, { Users, Tokens, Containers, Cluster, Settings });