Replaces the ZXP/CEP Premiere panel downloads with the UXP plugin: - data.jsx PREMIERE_RELEASES → single UXP 2.2.2 entry (ccx pointer) - home-page Premiere modal + Settings→Capture SDKs render the .ccx download and UXP install copy (UXP Developer Tool / Creative Cloud) - add downloads/dragonflight-mam-2.2.2.ccx (built from premiere-plugin-uxp) - remove the 1.0.0/1.0.1/1.1.0/1.2.0 .zxp + windows-setup.exe artifacts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2233 lines
108 KiB
JavaScript
2233 lines
108 KiB
JavaScript
// screens-admin.jsx - Users, Tokens, Containers, Cluster (graph), Settings
|
||
|
||
function _normalizeNode(n, x, y) {
|
||
const cap = n.capabilities || {};
|
||
|
||
// GPUs: capabilities.gpus entries with name+memory_mb = driver-bound (nvidia-smi confirmed).
|
||
// Entries with only type+device = detected by /dev file but driver status unknown.
|
||
const gpus = (cap.gpus || []).map(g => ({
|
||
name: g.name || (g.type ? g.type.toUpperCase() : 'GPU'),
|
||
memMb: g.memory_mb || null,
|
||
index: g.index ?? 0,
|
||
device: g.device || null,
|
||
bound: !!(g.name && g.memory_mb), // name+memory = nvidia-smi confirmed driver bound
|
||
}));
|
||
|
||
// Blackmagic DeckLink: capabilities.blackmagic + capabilities.blackmagic_model
|
||
const bmdPorts = (cap.blackmagic || []).map(b => ({
|
||
index: b.index ?? 0,
|
||
device: b.device || null,
|
||
model: cap.blackmagic_model || null,
|
||
online: b.online !== false,
|
||
}));
|
||
|
||
const memUsedMb = n.mem_used_mb || n.memory_used_mb || (n.mem && n.mem < 1000 ? n.mem * 1024 : n.mem || 0);
|
||
const memTotalMb = n.mem_total_mb || n.memory_total_mb || (n.memTotal && n.memTotal < 1000 ? n.memTotal * 1024 : n.memTotal || 0);
|
||
|
||
return {
|
||
id: n.hostname || n.id || n.name || 'node',
|
||
dbId: n.id,
|
||
role: n.role || 'worker',
|
||
status: n.status || (n.online ? 'online' : 'offline'),
|
||
ip: n.ip_address || n.ip || '·',
|
||
version: n.version || '·',
|
||
uptime: n.uptime || '·',
|
||
cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0),
|
||
mem: Math.round(memUsedMb / 1024 * 10) / 10,
|
||
memTotal: Math.round(memTotalMb / 1024 * 10) / 10,
|
||
// Raw capabilities for the hardware panel
|
||
gpus,
|
||
bmdPorts,
|
||
// Legacy flat arrays kept for the stat-row summary cards
|
||
gpuCount: gpus.length,
|
||
bmdCount: bmdPorts.length,
|
||
x, y,
|
||
};
|
||
}
|
||
|
||
function InviteUserModal({ onCreated, onClose }) {
|
||
const [form, setForm] = React.useState({ username: '', display_name: '', password: '', role: 'viewer' });
|
||
const [saving, setSaving] = React.useState(false);
|
||
const [err, setErr] = React.useState(null);
|
||
|
||
const submit = () => {
|
||
if (!form.username || !form.password) { setErr('Username and password are required'); return; }
|
||
setSaving(true); setErr(null);
|
||
window.ZAMPP_API.fetch('/users', { method: 'POST', body: JSON.stringify(form) })
|
||
.then(user => { onCreated(user); onClose(); })
|
||
.catch(e => { setSaving(false); setErr(e.message || 'Failed to create user'); });
|
||
};
|
||
|
||
const onKey = e => { if (e.key === 'Enter') submit(); };
|
||
|
||
return (
|
||
<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>
|
||
<div className="field">
|
||
<label className="field-label">Role</label>
|
||
<select className="field-input" value={form.role}
|
||
onChange={e => setForm(p => ({...p, role: e.target.value}))}
|
||
style={{ appearance: 'auto' }}>
|
||
<option value="viewer">Viewer</option>
|
||
<option value="editor">Editor</option>
|
||
<option value="admin">Admin</option>
|
||
</select>
|
||
</div>
|
||
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
|
||
<button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Creating…' : 'Create user'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Users() {
|
||
const [users, setUsers] = React.useState(window.ZAMPP_DATA.USERS || []);
|
||
const [groups, setGroups] = React.useState([]);
|
||
const [tab, setTab] = React.useState("users");
|
||
const [showInvite, setShowInvite] = React.useState(false);
|
||
const [editingUser, setEditingUser] = React.useState(null);
|
||
const [resetUser, setResetUser] = React.useState(null);
|
||
const [menuFor, setMenuFor] = React.useState(null); // row id whose menu is open
|
||
|
||
const refreshUsers = React.useCallback(() => {
|
||
window.ZAMPP_API.fetch('/users')
|
||
.then(list => {
|
||
const normalized = (list || []).map(u => ({
|
||
...u,
|
||
name: u.display_name || u.username,
|
||
initials: (u.display_name || u.username || '??').slice(0, 2).toUpperCase(),
|
||
group_count: u.group_count ?? 0,
|
||
}));
|
||
setUsers(normalized);
|
||
window.ZAMPP_DATA.USERS = normalized;
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const refreshGroups = React.useCallback(() => {
|
||
window.ZAMPP_API.fetch('/groups')
|
||
.then(list => setGroups(list || []))
|
||
.catch(() => setGroups([]));
|
||
}, []);
|
||
|
||
React.useEffect(() => { refreshUsers(); refreshGroups(); }, [refreshUsers, refreshGroups]);
|
||
|
||
// Click-outside closes any open row menu so the user can dismiss it without picking.
|
||
React.useEffect(() => {
|
||
if (!menuFor) return;
|
||
const close = () => setMenuFor(null);
|
||
window.addEventListener('click', close);
|
||
return () => window.removeEventListener('click', close);
|
||
}, [menuFor]);
|
||
|
||
const exportCsv = () => {
|
||
const rows = [['Username', 'Name', 'Role', 'Groups', 'Created']].concat(
|
||
users.map(u => [u.username || '', u.name || '', u.role || '', u.group_count || 0, u.created_at || ''])
|
||
);
|
||
const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n');
|
||
const a = document.createElement('a');
|
||
a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv);
|
||
a.download = 'users.csv';
|
||
a.click();
|
||
};
|
||
|
||
const onCreated = () => { refreshUsers(); setShowInvite(false); };
|
||
|
||
const deleteUser = (u) => {
|
||
setMenuFor(null);
|
||
if (!confirm(`Delete user "${u.name}" (@${u.username})?\nThis cannot be undone.`)) return;
|
||
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'DELETE' })
|
||
.then(refreshUsers)
|
||
.catch(e => alert('Delete failed: ' + e.message));
|
||
};
|
||
|
||
const resetPassword = (u) => { setMenuFor(null); setResetUser(u); };
|
||
|
||
const changeRole = (u, newRole) => {
|
||
if (u.role === newRole) return;
|
||
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
|
||
.then(refreshUsers)
|
||
.catch(e => alert('Role change failed: ' + e.message));
|
||
};
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-header">
|
||
<h1>Users & Groups</h1>
|
||
<div className="spacer" />
|
||
{tab === 'users' && (<>
|
||
<button className="btn ghost sm" onClick={exportCsv}><Icon name="download" />Export</button>
|
||
<button className="btn primary" onClick={() => setShowInvite(true)}><Icon name="plus" />Invite user</button>
|
||
</>)}
|
||
</div>
|
||
<div className="page-body">
|
||
<div className="tab-group" style={{ width: "fit-content", marginBottom: 12 }}>
|
||
<button className={tab === "users" ? "active" : ""} onClick={() => setTab("users")}>Users · {users.length}</button>
|
||
<button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups · {groups.length}</button>
|
||
<button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies</button>
|
||
</div>
|
||
|
||
{tab === 'users' && (
|
||
<div className="panel">
|
||
<div className="user-row head">
|
||
<div>User</div>
|
||
<div>Role</div>
|
||
<div>Groups</div>
|
||
<div>Created</div>
|
||
<div></div>
|
||
</div>
|
||
{users.length === 0 && (
|
||
<div style={{ padding: "32px 0", textAlign: "center", color: "var(--text-3)" }}>No users found</div>
|
||
)}
|
||
{users.map(u => (
|
||
<div key={u.id} className="user-row" style={{ position: 'relative' }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
|
||
<div>
|
||
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
|
||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>@{u.username}</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<select value={u.role || 'viewer'}
|
||
onChange={e => changeRole(u, e.target.value)}
|
||
className="field-input"
|
||
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
|
||
<option value="admin">admin</option>
|
||
<option value="editor">editor</option>
|
||
<option value="viewer">viewer</option>
|
||
</select>
|
||
</div>
|
||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
||
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
|
||
</div>
|
||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
||
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '·'}
|
||
</div>
|
||
<div style={{ position: 'relative' }}>
|
||
<button className="icon-btn" 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>
|
||
);
|
||
}
|
||
|
||
// Real Tokens admin page: wraps ApiTokensSection (defined further down) in a
|
||
// .page shell so it can be a top-level admin nav destination. The old parody
|
||
// pricing page lives below as TokensParody and is now routed at /billing in
|
||
// the Admin section.
|
||
function Tokens() {
|
||
return (
|
||
<div className="page">
|
||
<div className="page-header">
|
||
<h1>Tokens</h1>
|
||
<span className="subtitle">API tokens for the Premiere panel, node-agents, and external integrations</span>
|
||
</div>
|
||
<div className="page-body">
|
||
<ApiTokensSection />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TokensParody() {
|
||
const [burned, setBurned] = React.useState(14340);
|
||
const [rate, setRate] = React.useState(2.4);
|
||
const [showCalc, setShowCalc] = React.useState(false);
|
||
|
||
React.useEffect(() => {
|
||
const i = setInterval(() => {
|
||
setBurned(b => b + Math.floor(Math.random() * 8) + 1);
|
||
setRate(r => Math.max(0.8, Math.min(8, r + (Math.random() - 0.5) * 0.4)));
|
||
}, 800);
|
||
return () => clearInterval(i);
|
||
}, []);
|
||
|
||
const burnSpark = React.useMemo(() => Array.from({ length: 40 }, (_, i) => 100 + i * 8 + Math.sin(i * 0.7) * 12), []);
|
||
const competitorSpark = React.useMemo(() => Array.from({ length: 40 }, (_, i) => 200 + i * 22 + Math.cos(i * 0.5) * 30), []);
|
||
const yourCostSpark = React.useMemo(() => Array.from({ length: 40 }, () => 1), []);
|
||
|
||
const [events, setEvents] = React.useState([
|
||
{ t: "21:14:02", action: "preview thumbnail generated", cost: 4 },
|
||
{ t: "21:14:01", action: "user clicked play", cost: 12 },
|
||
{ t: "21:13:58", action: "API health check", cost: 8 },
|
||
{ t: "21:13:54", action: "asset metadata read", cost: 2 },
|
||
{ t: "21:13:51", action: "session token refreshed", cost: 18 },
|
||
{ t: "21:13:47", action: "scrubbed timeline 1 frame", cost: 6 },
|
||
{ t: "21:13:42", action: "took a deep breath near the API", cost: 24 },
|
||
]);
|
||
|
||
React.useEffect(() => {
|
||
const actions = [
|
||
"preview thumbnail generated", "user clicked play", "API health check",
|
||
"scrubbed timeline 1 frame", "asset metadata read", "session token refreshed",
|
||
"checked job queue", "rendered a tooltip", "loaded sidebar icon",
|
||
"blinked", "made eye contact with the cluster", "opened a modal (twice)",
|
||
"asset list pagination request", "thought about a comment", "moved cursor near 'Save'",
|
||
];
|
||
const i = setInterval(() => {
|
||
const now = new Date();
|
||
const t = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
||
const a = actions[Math.floor(Math.random() * actions.length)];
|
||
const c = Math.floor(Math.random() * 28) + 1;
|
||
setEvents(ev => [{ t, action: a, cost: c }, ...ev].slice(0, 12));
|
||
}, 1600);
|
||
return () => clearInterval(i);
|
||
}, []);
|
||
|
||
const tiers = [
|
||
{ name: "Starter", desc: "For \"evaluation only\": definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
|
||
{ name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
|
||
{ name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" },
|
||
];
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-header">
|
||
<h1>Billing</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:
|
||
<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 AccountSection() {
|
||
const [current, setCurrent] = React.useState('');
|
||
const [next, setNext] = React.useState('');
|
||
const [confirm, setConfirm] = React.useState('');
|
||
const [msg, setMsg] = React.useState(null); // { kind: 'ok'|'err', text }
|
||
const [busy, setBusy] = React.useState(false);
|
||
|
||
const submit = async () => {
|
||
setMsg(null);
|
||
if (next !== confirm) { setMsg({ kind: 'err', text: 'Passwords do not match' }); return; }
|
||
if (next.length < 12) { setMsg({ kind: 'err', text: 'New password must be at least 12 characters' }); return; }
|
||
setBusy(true);
|
||
try {
|
||
const r = await fetch('/api/v1/auth/password', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||
body: JSON.stringify({ current_password: current, new_password: next }),
|
||
});
|
||
if (r.status === 204) {
|
||
setMsg({ kind: 'ok', text: 'Password updated' });
|
||
setCurrent(''); setNext(''); setConfirm('');
|
||
} else {
|
||
const body = await r.json().catch(() => ({}));
|
||
setMsg({ kind: 'err', text: body.error || 'Failed (' + r.status + ')' });
|
||
}
|
||
} finally { setBusy(false); }
|
||
};
|
||
|
||
return (
|
||
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
|
||
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Account</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 10, alignItems: 'center', maxWidth: 480 }}>
|
||
<label>Current password</label>
|
||
<input type="password" autoComplete="current-password" className="field-input" value={current} onChange={e => setCurrent(e.target.value)} />
|
||
<label>New password</label>
|
||
<input type="password" autoComplete="new-password" className="field-input" value={next} onChange={e => setNext(e.target.value)} />
|
||
<label>Confirm new password</label>
|
||
<input type="password" autoComplete="new-password" className="field-input" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||
</div>
|
||
{msg && (
|
||
<div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>
|
||
)}
|
||
<button className="btn primary sm" style={{ marginTop: 12 }} disabled={busy || !current || !next || !confirm} onClick={submit}>
|
||
Change password
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ApiTokensSection() {
|
||
const [tokens, setTokens] = React.useState([]);
|
||
const [name, setName] = React.useState('');
|
||
const [justCreated, setJustCreated] = React.useState(null); // { token, prefix, name }
|
||
const [busy, setBusy] = React.useState(false);
|
||
|
||
const load = React.useCallback(async () => {
|
||
const r = await fetch('/api/v1/auth/tokens', { credentials: 'include' });
|
||
if (r.status === 200) setTokens(await r.json());
|
||
}, []);
|
||
|
||
React.useEffect(() => { load(); }, [load]);
|
||
|
||
const create = async () => {
|
||
if (!name.trim()) return;
|
||
setBusy(true);
|
||
try {
|
||
const r = await fetch('/api/v1/auth/tokens', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||
body: JSON.stringify({ name: name.trim() }),
|
||
});
|
||
if (r.status === 201) {
|
||
const created = await r.json();
|
||
setJustCreated(created);
|
||
setName('');
|
||
await load();
|
||
}
|
||
} finally { setBusy(false); }
|
||
};
|
||
|
||
const revoke = async (id) => {
|
||
await fetch('/api/v1/auth/tokens/' + id, {
|
||
method: 'DELETE',
|
||
credentials: 'include',
|
||
headers: { 'X-Requested-With': 'dragonflight-ui' },
|
||
});
|
||
await load();
|
||
};
|
||
|
||
return (
|
||
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
|
||
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>API Tokens</h3>
|
||
|
||
{justCreated && (
|
||
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
|
||
Save this token now: it will not be shown again
|
||
</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
|
||
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
|
||
<button className="btn sm" style={{ marginLeft: 8 }} onClick={() => setJustCreated(null)}>Dismiss</button>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||
<input className="field-input" placeholder="Token name (e.g. Premiere panel)" value={name} onChange={e => setName(e.target.value)} style={{ flex: 1 }} />
|
||
<button className="btn primary sm" disabled={busy || !name.trim()} onClick={create}>New token</button>
|
||
</div>
|
||
|
||
<div className="token-list">
|
||
{tokens.length === 0 && <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>No tokens yet.</div>}
|
||
{tokens.map(t => (
|
||
<div key={t.id} className="token-row" style={{ display: 'grid', gridTemplateColumns: '1fr 120px 140px 80px', gap: 10, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
|
||
<div>{t.name}</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-2)' }}>{t.prefix}…</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}</div>
|
||
<button className="btn sm" onClick={() => revoke(t.id)}>Revoke</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Settings() {
|
||
const [section, setSection] = React.useState('account');
|
||
|
||
const SECTIONS = [
|
||
{ id: 'account', label: 'Account', icon: 'user' },
|
||
{ id: 'storage', label: 'Storage', icon: 'hdd' },
|
||
{ id: 'proxy', label: 'Proxy encoding', icon: 'gpu' },
|
||
{ id: 'sdk', label: 'Capture SDKs', icon: 'video' },
|
||
{ id: 'sdi', label: 'SDI capture', icon: 'video' },
|
||
];
|
||
|
||
return (
|
||
<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 === 'account' && (
|
||
<>
|
||
<AccountSection />
|
||
<ApiTokensSection />
|
||
</>
|
||
)}
|
||
{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 UXP plugin enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro.
|
||
Install the <strong style={{ color: 'var(--text-2)' }}>.ccx</strong> via the <a href="https://developer.adobe.com/photoshop/uxp/2022/guides/devtool/installation/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>Adobe UXP Developer Tool</a>,
|
||
or double-click it with Creative Cloud installed.
|
||
</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>
|
||
{r.ccx && (
|
||
<a href={r.ccx} download style={{ textDecoration: 'none' }}>
|
||
<button className="btn ghost sm">UXP (.ccx)</button>
|
||
</a>
|
||
)}
|
||
{r.installer && (
|
||
<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/<vendor>/</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.setRequestHeader('X-Requested-With', 'dragonflight-ui');
|
||
xhr.withCredentials = true;
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
|
||
};
|
||
xhr.onload = () => {
|
||
setUploading(false); setProgress(0);
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
onDone(vendor.name + ': SDK staged.', true);
|
||
} else {
|
||
let txt = xhr.responseText;
|
||
try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
|
||
onDone(vendor.name + ': upload failed: ' + txt, false);
|
||
}
|
||
resolve();
|
||
};
|
||
xhr.onerror = () => {
|
||
setUploading(false); setProgress(0);
|
||
onDone(vendor.name + ': network error', false);
|
||
resolve();
|
||
};
|
||
xhr.send(fd);
|
||
});
|
||
};
|
||
|
||
const clear = () => {
|
||
if (!confirm('Remove staged ' + vendor.name + ' SDK files?')) return;
|
||
window.ZAMPP_API.fetch('/sdk/' + vendor.id, { method: 'DELETE' })
|
||
.then(() => onDone(vendor.name + ': cleared.', true))
|
||
.catch(e => onDone(vendor.name + ': ' + e.message, false));
|
||
};
|
||
|
||
return (
|
||
<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 });
|