dragonflight/services/web-ui/public/screens-admin.jsx
opencode 002e5acb82 auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
Scope (locked in via planning Q&A):
  - Identity: local accounts only (PG users table) + existing bearer
    tokens for headless callers.
  - Transport: httpOnly cookie session for browser, Bearer for API.
  - RBAC: admin / editor / viewer roles, plus an orthogonal
    is_client flag for external (agency, talent, customer) accounts.
  - Bootstrap: ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD env
    seed the first admin on a clean install. Set ADMIN_BOOTSTRAP_RESET
    to force-reset the named user (break-glass).
  - Rate limit: in-memory, 10 fails per 15min per (IP, username).
  - Password policy: \u22658 chars, mixed case, digit, symbol; small
    blocklist of common passwords; cannot equal username.
  - Self-service: change own display name + password. Everything
    else (role, is_client, other-user mgmt) is admin only.
  - Audit log: append-only table, indexed by actor + event_type +
    created_at, populated by every auth/admin event.

Files added:
  - services/mam-api/src/db/migrations/022-auth-rework.sql
        users.is_client + last_login_at + failed_attempts; audit_log
        table with FK to users (ON DELETE SET NULL).
  - services/mam-api/src/middleware/audit.js
        Fire-and-forget audit() helper. Caller never awaits, failure
        logs but never throws — auditing cannot break the request
        that triggered it.
  - services/mam-api/src/middleware/passwordPolicy.js
        Shared checkPassword(pw, { username }) used by setup, user
        create/update, and self-service password change.
  - services/mam-api/src/tasks/bootstrapAdmin.js
        Runs after migrations. No-ops unless ADMIN_BOOTSTRAP_USER +
        ADMIN_BOOTSTRAP_PASSWORD are set AND (users table empty OR
        ADMIN_BOOTSTRAP_RESET=true).
  - services/mam-api/src/routes/audit.js
        Admin-only GET /audit (paginated, filter by event_type /
        actor / target / date) and GET /audit/event-types.
  - services/web-ui/public/modal-account-settings.jsx
        Profile + Password tabs. Triggered by sidebar user button.

Files rewritten:
  - services/mam-api/src/routes/auth.js
        - POST /login: regenerate(), no manual save(); audit success/
          fail/lockout; updates last_login_at + failed_attempts.
        - POST /logout: destroys session, audits logout.
        - GET /me: returns is_client + last_login_at. Synthetic admin
          when AUTH_ENABLED=false.
        - GET /setup-status: drives login.html UI state.
        - POST /setup: blocked once any user exists; password policy.
        - POST /password: self-service. Requires current pw, runs
          policy, audits, invalidates other sessions implicitly via
          users.js if changed by admin.
        - PATCH /me: self-service display_name update.
  - services/mam-api/src/routes/users.js
        - is_client field in create/update/list/get.
        - Guardrails: cannot delete or demote last admin, cannot
          delete self, admins cannot be flagged is_client.
        - Password change invalidates all sessions for that user
          (DELETE FROM sessions WHERE sess->>'userId' = id).
        - Audit on every mutation.
        - Password policy enforced.
  - services/mam-api/src/middleware/auth.js
        - requireAuth now exposes req.user.is_client.
        - New requireRole(["admin","editor"], { rejectClients: true })
          helper. Applied to cluster, sdk, capture routes (infra).
        - Synthetic user when AUTH_ENABLED=false has is_client=false.
  - services/mam-api/src/index.js
        - Loads bootstrap admin after migrations.
        - Wires /api/v1/audit.
        - Cleans up an earlier comment block.
  - services/web-ui/public/login.html
        - Password hint added next to setup-mode password field.
  - services/web-ui/public/shell.jsx
        - Sidebar user footer is a button that opens AccountSettings.
        - CLIENT badge next to role when is_client=true.
        - Nav filters: clients lose ingest tree + jobs + editor;
          viewers lose ingest + editor; only admins see the Admin
          section. Power button hidden when synthetic user.
  - services/web-ui/public/screens-admin.jsx
        - Users table: new Client column with inline toggle.
        - InviteUserModal: Client checkbox + password hint, gated
          off when role=admin.
        - Last login column replaces Created in primary view.
        - CSV export includes client + last_login.
  - services/web-ui/public/data.jsx
        - ZAMPP_DATA.ME carries is_client + display_name.
  - services/web-ui/public/index.html
        - Loads dist/modal-account-settings.js.
  - services/web-ui/public/styles-rest.css
        - .user-row grid widened to 6 columns.
  - docker-compose.yml
        - Plumbs SESSION_COOKIE_SECURE + ADMIN_BOOTSTRAP_* env vars.

Deploy:
  cd /opt/wild-dragon
  git pull origin main
  # In .env:
  #   AUTH_ENABLED=true
  #   SESSION_SECRET=<openssl rand -hex 48>
  #   ADMIN_BOOTSTRAP_USER=admin
  #   ADMIN_BOOTSTRAP_PASSWORD=<strong>
  docker compose build mam-api web-ui
  docker compose up -d --force-recreate --no-deps mam-api web-ui
2026-05-27 03:21:16 +00:00

2106 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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', is_client: false });
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; }
// Admins cannot be clients — enforce client-side too.
const payload = { ...form, is_client: form.role === 'admin' ? false : form.is_client };
setSaving(true); setErr(null);
window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(payload) })
.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" aria-label="Close" 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}
autoComplete="new-password"
onChange={e => setForm(p => ({...p, password: e.target.value}))}
onKeyDown={onKey} placeholder="Temporary password" />
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>8+ chars, mixed case, digit, symbol</div>
</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, is_client: e.target.value === 'admin' ? false : p.is_client }))}
style={{ appearance: 'auto' }}>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="field">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, opacity: form.role === 'admin' ? 0.4 : 1 }}>
<input type="checkbox" checked={form.is_client} disabled={form.role === 'admin'}
onChange={e => setForm(p => ({...p, is_client: e.target.checked}))} />
<span style={{ color: 'var(--text-2)' }}>Client account</span>
<span style={{ color: 'var(--text-3)', fontSize: 11 }}>· hides recorder / cluster / infra surfaces</span>
</label>
</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', 'Client', 'Groups', 'Last login', 'Created']].concat(
users.map(u => [u.username || '', u.name || '', u.role || '', u.is_client ? 'yes' : 'no', u.group_count || 0, u.last_login_at || '', 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;
const body = { role: newRole };
if (newRole === 'admin') body.is_client = false;
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify(body) })
.then(refreshUsers)
.catch(e => alert('Role change failed: ' + e.message));
};
const toggleClient = (u) => {
if (u.role === 'admin') return;
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ is_client: !u.is_client }) })
.then(refreshUsers)
.catch(e => alert('Client toggle 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>Client</div>
<div>Groups</div>
<div>Last login</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>
<label
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-3)', cursor: u.role === 'admin' ? 'not-allowed' : 'pointer', opacity: u.role === 'admin' ? 0.4 : 1 }}
title={u.role === 'admin' ? 'Admins cannot be flagged as clients' : 'External client account'}>
<input type="checkbox" checked={!!u.is_client} disabled={u.role === 'admin'} onChange={() => toggleClient(u)} />
<span>{u.is_client ? 'yes' : 'no'}</span>
</label>
</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.last_login_at ? new Date(u.last_login_at).toLocaleString() : (u.created_at ? `created ${new Date(u.created_at).toLocaleDateString()}` : '—')}
</div>
<div style={{ position: 'relative' }}>
<button className="icon-btn" aria-label="User actions" 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" aria-label="Close" 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);
// #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 (
<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" aria-label="Close" 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" aria-label={show ? 'Hide password' : 'Show password'} 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" aria-label="Remove member" 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" aria-label="Close" 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);
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 (
<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" aria-label="Close" 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>
);
}
// ────────────────────────────────────────────────────────────────────────────
// 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 (
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="video" size={11} />
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : ' none reported'}
</div>
{sel.bmdPorts.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
)}
{sel.bmdPorts.length > 0 && (
<div style={{ padding: "8px 10px", background: "var(--bg-2)", borderRadius: 5, border: "1px solid rgba(91,124,250,0.2)" }}>
{/* Card header */}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<Icon name="video" size={13} style={{ color: "var(--accent)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>
{sel.bmdPorts[0].model || "Blackmagic DeckLink"}
</span>
<span style={{ marginLeft: "auto", fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3, background: "rgba(91,124,250,0.15)", color: "var(--accent)" }}>
{sel.bmdCount} PORT{sel.bmdCount !== 1 ? 'S' : ''}
</span>
</div>
{/* Port chips with signal state */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 10 }}>
{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 (
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} — ${label}` : label}
style={{
display: "flex", alignItems: "center", gap: 5,
fontSize: 10.5, fontFamily: "var(--font-mono)",
padding: "3px 8px", borderRadius: 3,
background: isReceiving ? "rgba(45,212,168,0.1)" : "rgba(255,255,255,0.04)",
border: `1px solid ${isReceiving ? "rgba(45,212,168,0.3)" : "var(--border)"}`,
}}>
{/* Signal presence dot */}
<span style={{
width: 6, height: 6, borderRadius: "50%", flexShrink: 0,
background: sig ? color : "var(--text-4)",
animation: isReceiving ? "signalPulse 1.4s ease-in-out infinite" : "none",
}} />
<span style={{ color: "var(--text-2)" }}>
{p.device ? p.device.split('/').pop() : `port ${p.index}`}
</span>
{sig && (
<span style={{ color, fontSize: 9, fontWeight: 700, marginLeft: 2, letterSpacing: "0.04em" }}>
{label}
</span>
)}
{sigEntry && sigEntry.currentFps != null && (
<span style={{ color: "var(--text-4)", fontSize: 9 }}>
{Number(sigEntry.currentFps).toFixed(1)} fps
</span>
)}
</div>
);
})}
</div>
{/* BMD SVG card diagram */}
<div ref={svgRef} className="bmd-card-diagram" />
</div>
)}
</div>
);
}
// 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 (
<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.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 (
<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.gpuCount, 0)}</div>
</div>
<div className="stat-card">
<div className="label"><Icon name="video" size={12} />Capture ports</div>
<div className="value">{NODES.reduce((a, n) => a + n.bmdCount, 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>
{(n.gpuCount > 0 || n.bmdCount > 0) && (
<text textAnchor="middle" y="67" fill="var(--text-4)" fontSize="9.5" fontFamily="var(--font-mono)">
{[n.gpuCount > 0 && `${n.gpuCount}×GPU`, n.bmdCount > 0 && `${n.bmdCount}×BMD`].filter(Boolean).join(' ')}
</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>
} />
)}
{/* ── GPU hardware ── */}
<div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} />
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '— none reported'}
</div>
{sel.gpus.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No GPUs detected on this node</div>
)}
{sel.gpus.map((g, i) => (
<div key={i} style={{
padding: "7px 10px", background: "var(--bg-2)", borderRadius: 5, marginBottom: 4,
border: g.bound ? "1px solid rgba(91,250,138,0.25)" : "1px solid var(--border)",
display: "flex", alignItems: "center", gap: 8,
}}>
<Icon name="gpu" size={12} style={{ color: g.bound ? "var(--success)" : "var(--text-3)", flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>{g.name}</div>
{g.memMb && (
<div style={{ fontSize: 11, color: "var(--text-3)", fontFamily: "var(--font-mono)", marginTop: 1 }}>
{g.memMb >= 1024 ? (g.memMb / 1024).toFixed(1) + ' GB' : g.memMb + ' MB'} VRAM
</div>
)}
{g.device && <div style={{ fontSize: 10.5, color: "var(--text-4)", fontFamily: "var(--font-mono)" }}>{g.device}</div>}
</div>
<span style={{
fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3,
background: g.bound ? "rgba(91,250,138,0.15)" : "rgba(255,255,255,0.05)",
color: g.bound ? "var(--success)" : "var(--text-3)",
}}>
{g.bound ? "BOUND" : "UNBOUND"}
</span>
</div>
))}
</div>
{/* ── Capture cards ── */}
<BmdCardPanel sel={sel} portSignals={portSignals} />
<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" aria-label="Close" 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: '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 (
<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' && <StorageSection />}
{section === 'proxy' && <GpuSettingsCard />}
{section === 'sdk' && <SdkSettingsCard />}
{section === 'sdi' && <SdiSettingsCard />}
</div>
</div>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// 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 (
<>
<MountHealthStrip />
<S3SettingsCard />
<GrowingSettingsCard />
</>
);
}
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 (
<span className={cls} title={detail || ''} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'currentColor', display: 'inline-block' }} />
{label}
</span>
);
}
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 (
<SettingsCard icon="hdd" title="Mount health" sub="Live diagnostics for the storage subsystem"
tag={<span className="badge warning">unavailable</span>}>
<SettingsMsg msg={{ ok: false, text: 'Could not load /storage/overview: ' + error }} />
</SettingsCard>
);
}
if (!data) {
return (
<SettingsCard icon="hdd" title="Mount health" sub="Live diagnostics for the storage subsystem">
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Probing</div>
</SettingsCard>
);
}
const g = data.growing;
const s = data.s3;
const growingHealthy = g.enabled ? (g.exists && g.writable) : true;
return (
<SettingsCard icon="hdd" title="Mount health" sub="Live diagnostics for the storage subsystem"
tag={
<button className="btn ghost sm" onClick={load} disabled={refreshing} title="Re-probe now"
style={{ padding: '2px 8px' }}>
{refreshing ? '…' : 'Refresh'}
</button>
}>
{/* ── Growing-files row ─────────────────────────────────────────────── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 12.5 }}>Growing files</strong>
{g.enabled
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'mounted' : 'unreachable'} detail={g.error || ''} />
: <span className="badge neutral">disabled</span>}
{g.enabled && g.exists && (
<HealthPill ok={g.writable} label={g.writable ? 'writable' : 'read-only'} />
)}
{g.free_bytes != null && (
<span className="badge neutral" title={g.total_bytes ? `of ${formatBytes(g.total_bytes)} total` : ''}>
{formatBytes(g.free_bytes)} free
</span>
)}
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Container</span><span className="mono">{g.container_path || ''}</span>
<span>Host</span><span className="mono">{g.host_path || ''}</span>
<span>SMB</span><span className="mono">{g.smb_url || ''}</span>
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
</div>
</div>
<div style={{ height: 1, background: 'var(--border-1, rgba(255,255,255,0.06))', margin: '10px 0' }} />
{/* ── S3 bucket row ─────────────────────────────────────────────────── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 12.5 }}>S3 bucket</strong>
<HealthPill ok={s.reachable} label={s.reachable ? 'reachable' : 'unreachable'} detail={s.error || ''} />
{s.head_latency_ms != null && (
<span className="badge neutral">{s.head_latency_ms} ms</span>
)}
{s.probe_method && <span className="badge neutral">{s.probe_method}</span>}
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Endpoint</span><span className="mono">{s.endpoint || '(AWS default)'}</span>
<span>Bucket</span><span className="mono">{s.bucket || ''}</span>
<span>Region</span><span className="mono">{s.region || ''}</span>
{s.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{s.error}</span></>}
</div>
</div>
</SettingsCard>
);
}
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> : (
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<SField label="Endpoint URL">
<input className="field-input mono" type="url" required 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" required 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" required 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" required value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /></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'} autoComplete="new-password" /></SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
<button type="button" className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div>
</form>
)}
</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>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<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 type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</form>
</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>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<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 type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving' : 'Save'}</button>
</div>
</form>
</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',
},
];
// 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 (
<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>}>
{/* ── Premiere Panel download section ── */}
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Premiere Pro Panel
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 10 }}>
The Dragonflight CEP panel enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro.
Install the <strong style={{ color: 'var(--text-2)' }}>.zxp</strong> via <a href="https://zxpsign.com/zxp-installer" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>ZXP Installer</a> (Mac/Win),
or run the <strong style={{ color: 'var(--text-2)' }}>Windows Setup</strong> which bundles the installer automatically.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{PREMIERE_RELEASES.map(r => (
<div key={r.version} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px', background: 'var(--bg-2)', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<strong style={{ fontSize: 13 }}>v{r.version}</strong>
{r.latest && <span className="badge success">latest</span>}
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{r.notes}</div>
</div>
<a href={r.zxp} download style={{ textDecoration: 'none' }}>
<button className="btn ghost sm">ZXP</button>
</a>
<a href={r.installer} download style={{ textDecoration: 'none' }}>
<button className="btn ghost sm">Win Installer</button>
</a>
</div>
))}
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', marginBottom: 14 }} />
{/* ── Capture SDK upload section ── */}
<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', (window.ZAMPP_API_PREFIX || '/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>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<SField label="AMPP base URL">
<input className="field-input mono" type="url" required 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'} autoComplete="new-password" />
</SField>
<SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div>
</form>
</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 });