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