// 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
    // Issue #166 — live NVENC/GPU encode telemetry folded into capabilities.gpus
    // by the node-agent heartbeat (null until a heartbeat carries it / a GPU node).
    utilPct:   g.util_pct      != null ? g.util_pct      : null,
    encUtilPct: g.enc_util_pct != null ? g.enc_util_pct  : null,
    memUsedMb: g.mem_used_mb    != null ? g.mem_used_mb   : null,
    nvencSessions: g.nvenc_sessions != null ? g.nvenc_sessions : null,
  }));

  // 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,
  }));

  // Deltacast ports — used by the Capture Drivers panel as a secondary
  // "driver present?" signal (heartbeat only reports a port if the card is seen).
  const deltacastPorts = (cap.deltacast || []).map(d => ({
    index:  d.index ?? 0,
    device: d.device || null,
  }));

  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,
    deltacastPorts,
    // 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 [confirm, confirmModal] = window.useConfirm();

  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 = async (u) => {
    setMenuFor(null);
    if (!(await confirm({ title: 'Delete user?', message: `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">
      {confirmModal}
      <div className="page-header">
        <h1>Users &amp; Groups</h1>
        <div className="spacer" />
        {tab === 'users' && (<>
          <button className="btn ghost sm" onClick={exportCsv}><Icon name="download" />Export</button>
          <button className="btn primary" onClick={() => setShowInvite(true)}><Icon name="plus" />Invite user</button>
        </>)}
      </div>
      <div className="page-body">
        <div className="tab-group" style={{ width: "fit-content", marginBottom: 12 }}>
          <button className={tab === "users" ? "active" : ""} onClick={() => setTab("users")}>Users · {users.length}</button>
          <button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups · {groups.length}</button>
          <button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies</button>
        </div>

        {tab === 'users' && (
          <div className="panel">
            <div className="user-row head">
              <div>User</div>
              <div>Role</div>
              <div>Groups</div>
              <div>Created</div>
              <div></div>
            </div>
            {users.length === 0 && (
              <div style={{ padding: "32px 0", textAlign: "center", color: "var(--text-3)" }}>No users found</div>
            )}
            {users.map(u => (
              <div key={u.id} className="user-row" style={{ position: 'relative' }}>
                <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                  <div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
                  <div>
                    <div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
                    <div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>@{u.username}</div>
                  </div>
                </div>
                <div>
                  <select value={u.role || 'viewer'}
                          onChange={e => changeRole(u, e.target.value)}
                          className="field-input"
                          style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
                    <option value="admin">admin</option>
                    <option value="editor">editor</option>
                    <option value="viewer">viewer</option>
                  </select>
                </div>
                <div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
                  {u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
                </div>
                <div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
                  {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '·'}
                </div>
                <div style={{ position: 'relative' }}>
                  <button className="icon-btn" 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' && (
          <PoliciesPanel users={users} onChange={refreshUsers} />
        )}
      </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>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// PoliciesPanel - interactive per-user permission matrix for the Policies tab.
// Keeps the access-model explainer as a small header, then renders one row per
// user with: inline role <select> (PATCH /users/:id), a 2FA badge driven by
// totp_enabled, an admin-only "Reset 2FA" action (POST /users/:id/totp/disable,
// 204), and an Access expander backed by GET /users/:id/access.
// ────────────────────────────────────────────────────────────────────────────
function PoliciesPanel({ users, onChange }) {
  const [expandedId, setExpandedId] = React.useState(null);
  const [err, setErr] = React.useState(null);
  const [confirm, confirmModal] = window.useConfirm();

  const changeRole = (u, newRole) => {
    if (u.role === newRole) return;
    setErr(null);
    window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
      .then(() => onChange && onChange())
      .catch(e => setErr('Role change failed: ' + (e.message || e)));
  };

  // Reset 2FA uses a raw fetch because ZAMPP_API.fetch throws on the 204 (no JSON
  // body). Mirrors the disable() pattern in TotpSection.
  const resetTotp = async (u) => {
    if (!(await confirm({ title: 'Reset two-factor?', message: `Reset two-factor for "${u.name}" (@${u.username})?\nThey will be able to sign in without a code until they re-enrol.`, confirmLabel: 'Reset 2FA' }))) return;
    setErr(null);
    fetch('/api/v1/users/' + u.id + '/totp/disable', {
      method: 'POST',
      credentials: 'include',
      headers: { 'X-Requested-With': 'dragonflight-ui' },
    })
      .then(r => {
        if (r.status === 204) { onChange && onChange(); return; }
        return r.json().catch(() => ({})).then(b => { throw new Error(b.error || ('Failed (' + r.status + ')')); });
      })
      .catch(e => setErr('Reset 2FA failed: ' + (e.message || e)));
  };

  return (
    <div>
      {confirmModal}
      {/* Access-model explainer (kept from the old static tab, condensed) */}
      <div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
          <Icon name="lock" size={15} />
          <div style={{ fontWeight: 600, fontSize: 13.5 }}>Access model</div>
        </div>
        <div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.6, maxWidth: 720 }}>
          <strong style={{ color: 'var(--text-2)' }}>admin</strong> has full access to every project plus
          user, group, cluster, and system administration. <strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see
          only the projects they're granted — a <em>view</em> grant is read-only, an <em>edit</em> grant
          allows changes, and grants can target a user or a group. Edit per-project grants from the{' '}
          <a href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('df:nav', { detail: 'projects' })); }}
             style={{ color: 'var(--accent-text)' }}>Projects</a> page; manage group membership on the Groups tab above.
        </div>
      </div>

      {err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}

      <div className="panel">
        <div className="user-row head">
          <div>User</div>
          <div>Role</div>
          <div>2FA</div>
          <div>Access</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 => (
          <UserPolicyRow key={u.id} user={u}
            expanded={expandedId === u.id}
            onToggle={() => setExpandedId(expandedId === u.id ? null : u.id)}
            onChangeRole={changeRole}
            onResetTotp={resetTotp} />
        ))}
      </div>
    </div>
  );
}

function UserPolicyRow({ user: u, expanded, onToggle, onChangeRole, onResetTotp }) {
  const [access, setAccess] = React.useState(null);  // null = not loaded, {} once fetched
  const [loading, setLoading] = React.useState(false);
  const [accessErr, setAccessErr] = React.useState(null);

  // Lazily fetch GET /users/:id/access the first time the row is expanded.
  React.useEffect(() => {
    if (!expanded || access !== null) return;
    setLoading(true); setAccessErr(null);
    window.ZAMPP_API.fetch('/users/' + u.id + '/access')
      .then(d => setAccess(d || {}))
      .catch(e => { setAccess({}); setAccessErr(e.message || 'Failed to load access'); })
      .finally(() => setLoading(false));
  }, [expanded, access, u.id]);

  const projects = (access && access.projects) || [];
  const memberships = (access && (access.groups || access.memberships)) || [];

  return (
    <div style={{ borderBottom: '1px solid var(--border)' }}>
      <div className="user-row" style={{ borderBottom: 'none' }}>
        <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 => onChangeRole(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>
          {u.totp_enabled
            ? <span className="badge success"><Icon name="key" size={10} /> 2FA on</span>
            : <span className="badge neutral">2FA off</span>}
        </div>
        <div>
          <button className="btn ghost sm" onClick={onToggle}>
            {expanded ? 'Hide' : 'View'}
          </button>
        </div>
        <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
          {u.totp_enabled && (
            <button className="btn ghost sm danger" onClick={() => onResetTotp(u)} title="Disable this user's two-factor">
              <Icon name="key" size={11} />Reset 2FA
            </button>
          )}
        </div>
      </div>

      {expanded && (
        <div style={{ padding: '0 16px 16px 16px', background: 'var(--bg-2)' }}>
          {loading && <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)' }}>Loading access…</div>}
          {accessErr && <div style={{ padding: '8px 0', fontSize: 12, color: 'var(--danger)' }}>{accessErr}</div>}
          {!loading && !accessErr && (u.role === 'admin') && (
            <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 6 }}>
              <Icon name="check" size={12} style={{ color: 'var(--success)' }} />
              Admin: full access to every project.
            </div>
          )}
          {!loading && !accessErr && u.role !== 'admin' && (
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, paddingTop: 12 }}>
              {/* Accessible projects */}
              <div>
                <div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
                  Projects ({projects.length})
                </div>
                {projects.length === 0 && (
                  <div style={{ fontSize: 12, color: 'var(--text-4)' }}>No project access granted.</div>
                )}
                {projects.map(p => {
                  // Backend `via` is 'direct' for a user grant, or 'group:<name>'
                  // when inherited from a group. Split the label off the prefix.
                  const via = p.via || 'direct';
                  const isGroup = via.indexOf('group') === 0;
                  const viaLabel = isGroup ? (via.indexOf(':') >= 0 ? via.slice(via.indexOf(':') + 1) : 'group') : 'direct';
                  return (
                    <div key={(p.project_id || p.id) + ':' + via}
                         style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
                      <span style={{ fontSize: 12.5, flex: 1 }}>{p.project_name || p.name || p.project_id || p.id}</span>
                      <span className={`badge ${(p.level === 'edit') ? 'accent' : 'neutral'}`}>{p.level || 'view'}</span>
                      <span className="badge neutral" title={isGroup ? 'Inherited from group ' + viaLabel : 'Granted directly'}>
                        <Icon name={isGroup ? 'users' : 'user'} size={9} /> {viaLabel}
                      </span>
                    </div>
                  );
                })}
              </div>
              {/* Group memberships */}
              <div>
                <div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
                  Groups ({memberships.length})
                </div>
                {memberships.length === 0 && (
                  <div style={{ fontSize: 12, color: 'var(--text-4)' }}>Not a member of any group.</div>
                )}
                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                  {memberships.map(g => (
                    <span key={g.id || g.group_id || g.name} className="badge neutral" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
                      <Icon name="users" size={9} />{g.name || g.group_name || g.group_id}
                    </span>
                  ))}
                </div>
              </div>
            </div>
          )}
        </div>
      )}
    </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 [confirm, confirmModal] = window.useConfirm();

  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 = async (g) => {
    if (!(await confirm({ title: 'Delete group?', message: `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>
      {confirmModal}
      <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);
  const [confirm, confirmModal] = window.useConfirm();
  // #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 = async (c) => {
    if (!(await confirm({ title: 'Restart container?', message: 'Restart container "' + c.name + '"?\nIn-flight requests will be dropped.', confirmLabel: 'Restart' }))) 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">
      {confirmModal}
      <div className="page-header">
        <h1>Containers</h1>
        <span className="subtitle">Docker Compose services across the cluster</span>
        <div className="spacer" />
        {containers !== null && containers.length > 0 && (
          <div className="status-pip">
            <span className="dot" />
            <span>{running} / {containers.length} running</span>
          </div>
        )}
        <button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
      </div>
      {restartFlash && (
        <div style={{
          position: 'fixed', bottom: 20, right: 20, zIndex: 200,
          background: restartFlash.status === 'ok' ? 'var(--success-soft)'
                    : restartFlash.status === 'fail' ? 'var(--danger-soft)'
                    : 'var(--bg-2)',
          color: restartFlash.status === 'ok' ? 'var(--success)'
               : restartFlash.status === 'fail' ? 'var(--danger)' : 'var(--text-2)',
          border: '1px solid', borderColor: restartFlash.status === 'ok' ? 'var(--success)'
                                          : restartFlash.status === 'fail' ? 'var(--danger)' : 'var(--border)',
          borderRadius: 6, padding: '10px 14px', fontSize: 12.5, maxWidth: 320,
        }}>
          {restartFlash.status === 'pending' && `Restarting ${restartFlash.name}…`}
          {restartFlash.status === 'ok' && `${restartFlash.name} restarted.`}
          {restartFlash.status === 'fail' && `${restartFlash.name}: ${restartFlash.error}`}
        </div>
      )}
      {logsModal && (
        <div className="modal-backdrop" onClick={() => setLogsModal(null)}>
          <div className="modal" style={{ width: 540 }} onClick={e => e.stopPropagation()}>
            <div className="modal-head">
              <div style={{ fontSize: 15, fontWeight: 600 }}>Logs · {logsModal.name}</div>
              <button className="icon-btn" aria-label="Close" onClick={() => setLogsModal(null)}><Icon name="x" /></button>
            </div>
            <div className="modal-body">
              <div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}>
                Live log streaming over the websocket isn't wired yet. SSH to the host that runs this container and tail the logs directly:
              </div>
              <code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 10, borderRadius: 5, fontSize: 11.5, overflowX: 'auto' }}>
                docker compose logs -f {logsModal.name}
              </code>
              <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 10 }}>
                Or grab the last 200 lines:&nbsp;
                <span className="mono">docker logs --tail 200 {logsModal.name}</span>
              </div>
            </div>
            <div className="modal-foot">
              <button className="btn ghost sm" onClick={() => {
                if (navigator.clipboard) navigator.clipboard.writeText('docker compose logs -f ' + logsModal.name).catch(() => {});
              }}>Copy command</button>
              <button className="btn primary sm" onClick={() => setLogsModal(null)}>Close</button>
            </div>
          </div>
        </div>
      )}
      <div className="page-body">
        {containers === null && (
          <div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading…</div>
        )}
        {containers !== null && containers.length === 0 && (
          <div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>
            <div style={{ fontSize: 32, marginBottom: 12 }}>🐳</div>
            <div style={{ fontWeight: 500, fontSize: 14 }}>No containers returned</div>
            <div style={{ fontSize: 12, marginTop: 6 }}>Confirm <code>/var/run/docker.sock</code> is mounted in the mam-api container</div>
          </div>
        )}
        {containers !== null && containers.length > 0 && (
          <div className="panel">
            <div className="container-row head">
              <div>Container</div>
              <div>Image</div>
              <div>State</div>
              <div>CPU</div>
              <div>Memory</div>
              <div>Ports</div>
              <div></div>
            </div>
            {containers.map(c => (
              <div key={c.id || c.name} className="container-row">
                <div>
                  <div style={{ fontWeight: 500, fontSize: 13 }}>{c.name}</div>
                  <div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>up {c.uptime}</div>
                </div>
                <div className="mono" style={{ fontSize: 11.5, color: "var(--text-2)" }}>{c.image}</div>
                <div>
                  <span className="badge success"><StatusDot status="online" /> RUNNING</span>
                  {c.healthy && <span style={{ fontSize: 10.5, color: "var(--success)", marginLeft: 6 }}>healthy</span>}
                </div>
                <div className="mono" style={{ fontSize: 11.5 }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                    <div style={{ width: 40, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
                      <div style={{ width: `${Math.min((c.cpu || 0) * 4, 100)}%`, height: "100%", background: "var(--accent)" }} />
                    </div>
                    <span>{(c.cpu || 0).toFixed(1)}%</span>
                  </div>
                </div>
                <div className="mono" style={{ fontSize: 11.5 }}>
                  {c.memBytes != null
                    ? `${Math.round(c.memBytes / 1048576)} MB`
                    : "N/A"}
                </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>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// DriverPanel - "Capture Drivers / SDKs" section inside the node detail panel.
// Per vendor (Blackmagic / AJA / Deltacast / NDI): shows detected status from
// GET /cluster/:id/driver-status (host probe) cross-checked with heartbeat
// capabilities, plus an "Install / Update" button that POSTs
// /cluster/:id/install-driver {vendor} and streams the agent log into a live
// output area, surfacing success/failure + "reboot required".
const DRIVER_VENDORS = [
  { key: 'blackmagic', label: 'Blackmagic', hint: 'Desktop Video driver (.deb)' },
  { key: 'aja',        label: 'AJA',        hint: 'NTV2 driver / SDK' },
  { key: 'deltacast',  label: 'Deltacast',  hint: 'VideoMaster installer' },
  { key: 'ndi',        label: 'NDI',        hint: 'Redistributable runtime libs' },
];

function DriverPanel({ sel }) {
  const [status, setStatus] = React.useState(null);   // { kernel, vendors:{...} }
  const [loading, setLoading] = React.useState(true);
  const [statusErr, setStatusErr] = React.useState(null);
  const [busy, setBusy] = React.useState(null);        // vendor key currently installing
  const [log, setLog] = React.useState(null);          // { vendor, text, ok, rebootRequired }

  const loadStatus = React.useCallback(() => {
    if (!sel.dbId) return;
    setLoading(true); setStatusErr(null);
    window.ZAMPP_API.fetch(`/cluster/${sel.dbId}/driver-status`)
      .then(d => { setStatus(d); setLoading(false); })
      .catch(e => { setStatusErr(e.message || 'unreachable'); setLoading(false); });
  }, [sel.dbId]);

  React.useEffect(() => { loadStatus(); }, [loadStatus]);

  // Heartbeat-reported capabilities give a second signal for the two card types
  // the cluster already enumerates (Blackmagic ports, Deltacast ports).
  const capPresent = (vendor) => {
    if (vendor === 'blackmagic') return (sel.bmdPorts || []).length > 0;
    if (vendor === 'deltacast')  return (sel.deltacastPorts || []).length > 0;
    return false;
  };

  const isInstalled = (vendor) => {
    const v = status && status.vendors && status.vendors[vendor];
    return (v && v.installed) || capPresent(vendor);
  };

  const install = (vendor) => {
    setBusy(vendor);
    setLog({ vendor, text: `[ui] requesting install of ${vendor} on ${sel.id}…\n`, ok: null, rebootRequired: false });
    // Raw fetch: we need the JSON body (logs) even on a non-2xx response.
    fetch(`/api/v1/cluster/${sel.dbId}/install-driver`, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
      body: JSON.stringify({ vendor }),
    })
      .then(async (res) => {
        let body = {};
        try { body = await res.json(); } catch (_) {}
        const text = (body.logs && body.logs.trim())
          ? body.logs
          : (body.error || `Install ${res.ok ? 'completed' : 'failed'} (HTTP ${res.status})`);
        setLog({ vendor, text, ok: !!body.ok, rebootRequired: !!body.rebootRequired });
        setBusy(null);
        loadStatus();
      })
      .catch((e) => {
        setLog({ vendor, text: `[ui] request failed: ${e.message}`, ok: false, rebootRequired: false });
        setBusy(null);
      });
  };

  return (
    <div style={{ marginTop: 10 }}>
      <div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
        <Icon name="download" size={11} />
        Capture Drivers / SDKs
        {status && status.kernel && (
          <span style={{ marginLeft: "auto", fontSize: 10, color: "var(--text-4)", fontFamily: "var(--font-mono)" }}>
            kernel {status.kernel}
          </span>
        )}
      </div>

      {statusErr && (
        <div style={{ fontSize: 11, color: "var(--danger)", marginBottom: 6 }}>
          Driver status unavailable: {statusErr}
        </div>
      )}

      <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
        {DRIVER_VENDORS.map(v => {
          const installed = isInstalled(v.key);
          const installing = busy === v.key;
          return (
            <div key={v.key} style={{
              display: "flex", alignItems: "center", gap: 8,
              padding: "6px 10px", background: "var(--bg-2)", borderRadius: 5,
              border: "1px solid var(--border)",
            }}>
              <div style={{ minWidth: 0 }}>
                <div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>{v.label}</div>
                <div style={{ fontSize: 10.5, color: "var(--text-4)" }}>{v.hint}</div>
              </div>
              <span style={{
                marginLeft: "auto", fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3,
                background: installed ? "rgba(91,250,138,0.15)" : "rgba(255,255,255,0.05)",
                color: installed ? "var(--success)" : "var(--text-3)",
              }}>
                {loading ? "…" : (installed ? "INSTALLED" : "NOT INSTALLED")}
              </span>
              <button className="btn ghost sm" disabled={installing || !!busy}
                onClick={() => install(v.key)}>
                {installing ? "Installing…" : (installed ? "Update" : "Install")}
              </button>
            </div>
          );
        })}
      </div>

      {log && (
        <div style={{ marginTop: 8 }}>
          <div style={{ fontSize: 10.5, marginBottom: 4, display: "flex", alignItems: "center", gap: 6 }}>
            <span style={{ color: "var(--text-3)" }}>{log.vendor} install output</span>
            {log.ok === true && <span style={{ color: "var(--success)", fontWeight: 700 }}>SUCCESS</span>}
            {log.ok === false && <span style={{ color: "var(--danger)", fontWeight: 700 }}>FAILED</span>}
            {log.rebootRequired && (
              <span style={{ marginLeft: "auto", fontSize: 10, fontWeight: 700, color: "var(--warning, #f0a500)" }}>
                ⚠ REBOOT REQUIRED
              </span>
            )}
          </div>
          <pre style={{
            margin: 0, maxHeight: 180, overflow: "auto",
            fontSize: 10.5, lineHeight: 1.4, fontFamily: "var(--font-mono)",
            color: "var(--text-2)", background: "var(--bg-1)",
            border: "1px solid var(--border)", borderRadius: 4, padding: "6px 8px",
            whiteSpace: "pre-wrap", wordBreak: "break-word",
          }}>{log.text}</pre>
        </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 [confirm, confirmModal] = window.useConfirm();

  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 [showAddNode, setShowAddNode] = React.useState(false);
  const addNode = () => setShowAddNode(true);

  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 = async (node) => {
    if (!(await confirm({ title: 'Remove node?', message: 'Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine: it only removes it from cluster membership.', confirmLabel: 'Remove' }))) 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">
      {confirmModal}
      <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>}
                        {/* Issue #166 — live NVENC/GPU encode telemetry (0 until a live encode runs) */}
                        {(g.utilPct != null || g.encUtilPct != null || g.nvencSessions != null) && (
                          <div style={{ display: "flex", flexWrap: "wrap", gap: "4px 10px", marginTop: 4, fontSize: 10.5, fontFamily: "var(--font-mono)" }}>
                            {g.utilPct != null && (
                              <span style={{ color: "var(--text-3)" }}>GPU <strong style={{ color: "var(--text-2)" }}>{g.utilPct}%</strong></span>
                            )}
                            {g.encUtilPct != null && (
                              <span style={{ color: "var(--text-3)" }}>ENC <strong style={{ color: g.encUtilPct > 0 ? "var(--success)" : "var(--text-2)" }}>{g.encUtilPct}%</strong></span>
                            )}
                            {g.memUsedMb != null && g.memMb && (
                              <span style={{ color: "var(--text-3)" }}>VRAM <strong style={{ color: "var(--text-2)" }}>{g.memUsedMb}/{g.memMb} MB</strong></span>
                            )}
                            {g.nvencSessions != null && (
                              <span style={{ color: "var(--text-3)" }}>NVENC <strong style={{ color: g.nvencSessions > 0 ? "var(--success)" : "var(--text-2)" }}>{g.nvencSessions}</strong></span>
                            )}
                          </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} />

                {/* ── Capture Drivers / SDKs ── */}
                <DriverPanel sel={sel} />
                <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>
      {showAddNode && <AddNodeModal onClose={() => setShowAddNode(false)} />}
      {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>
  );
}

// AddNodeModal — Approach A onboarding wizard. Collects a node name, mints a
// one-time auth token via /auth/tokens, and renders a ready-to-paste
// `curl … | bash` command that provisions the machine via deploy/onboard-node.sh.
//
// No role picker: the new node self-detects its hardware (GPU / DeckLink /
// Deltacast) in onboard-node.sh and auto-enables the matching compose profiles
// (worker always; + gpu / + capture when present). Zero manual choice.
function AddNodeModal({ onClose }) {
  const [nodeName, setNodeName] = React.useState('');
  const [apiUrl,   setApiUrl]   = React.useState('');
  const [info,     setInfo]     = React.useState(null);   // { scriptUrl, branch }
  const [command,  setCommand]  = React.useState(null);   // generated string
  const [error,    setError]    = React.useState(null);
  const [busy,     setBusy]     = React.useState(false);
  const [copied,   setCopied]   = React.useState(false);

  // On open, prefill the editable apiUrl + capture scriptUrl/branch.
  React.useEffect(() => {
    window.ZAMPP_API.fetch('/cluster/onboard-info')
      .then(d => {
        setInfo({ scriptUrl: d.scriptUrl, branch: d.branch });
        if (d.apiUrl) setApiUrl(d.apiUrl);
      })
      .catch(() => {});  // leave apiUrl empty → user must fill it before Generate
  }, []);

  const generate = async () => {
    setError(null);
    if (!nodeName.trim()) { setError('Node name is required.'); return; }
    if (!apiUrl.trim())   { setError('Primary API URL is required.'); 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: 'node: ' + nodeName.trim() }),
      });
      if (r.status !== 201) {
        const body = await r.json().catch(() => ({}));
        setError(body.error || ('Failed to mint token (' + r.status + ')'));
        return;
      }
      const { token } = await r.json();
      const scriptUrl = (info && info.scriptUrl)
        || 'https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh';
      const cmd =
        `curl -sL ${scriptUrl} | NODE_TOKEN=${token} MAM_API_URL=${apiUrl.trim()} bash`;
      setCommand(cmd);
    } catch (e) {
      setError(e.message || 'Network error');
    } finally {
      setBusy(false);
    }
  };

  const copy = () => {
    if (!command || !navigator.clipboard) return;
    navigator.clipboard.writeText(command)
      .then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); })
      .catch(() => {});
  };

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" style={{ width: 620 }} onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div style={{ fontSize: 15, fontWeight: 600 }}>Add cluster node</div>
          <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
        </div>
        <div className="modal-body">
          {!command && (
            <React.Fragment>
              <div style={{ marginBottom: 14 }}>
                <label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Node name</label>
                <input className="field-input" style={{ width: '100%' }} autoFocus
                  placeholder="e.g. zampp3"
                  value={nodeName} onChange={e => setNodeName(e.target.value)} />
              </div>

              <div style={{ marginBottom: 4 }}>
                <label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Primary API URL</label>
                <input className="field-input mono" style={{ width: '100%', fontSize: 12 }}
                  placeholder="http://10.0.0.25:47432"
                  value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
                <div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 4 }}>
                  The LAN address this new node will heartbeat to. Edit if the guess is wrong.
                </div>
              </div>
            </React.Fragment>
          )}

          {command && (
            <React.Fragment>
              <div style={{ marginBottom: 10, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
                <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)' }}>
                  This token is shown only once. Copy the command now.
                </div>
              </div>
              <code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 11.5, lineHeight: 1.5, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{command}</code>
              <div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 8, lineHeight: 1.5 }}>
                Profiles (worker / capture / GPU) are auto-selected from the new machine's detected hardware — no need to choose.
              </div>
              <ol style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.6 }}>
                <li>SSH into the fresh Ubuntu machine.</li>
                <li>Paste and run this command.</li>
                <li>The node appears in this Cluster view within ~30s.</li>
              </ol>
            </React.Fragment>
          )}

          {error && (
            <div style={{ marginTop: 10, fontSize: 11.5, color: 'var(--danger)' }}>{error}</div>
          )}
        </div>
        <div className="modal-foot">
          {!command && (
            <React.Fragment>
              <button className="btn ghost sm" onClick={onClose}>Cancel</button>
              <button className="btn primary sm" disabled={busy} onClick={generate}>
                {busy ? 'Generating…' : 'Generate command'}
              </button>
            </React.Fragment>
          )}
          {command && (
            <React.Fragment>
              <button className="btn ghost sm" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
              <button className="btn primary sm" onClick={onClose}>Done</button>
            </React.Fragment>
          )}
        </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>
  );
}

// Two-factor (TOTP) enrollment + management. Reflects window.ZAMPP_DATA.ME.totp_enabled.
function TotpSection() {
  const me = window.ZAMPP_DATA?.ME || {};
  const [enabled, setEnabled] = React.useState(!!me.totp_enabled);
  const [phase, setPhase]     = React.useState('idle'); // idle | enrolling | recovery
  const [enroll, setEnroll]   = React.useState(null);   // { secret, otpauth_uri, qr }
  const [code, setCode]       = React.useState('');
  const [recovery, setRecovery] = React.useState(null); // string[]
  const [disablePw, setDisablePw] = React.useState('');
  const [showDisable, setShowDisable] = React.useState(false);
  const [msg, setMsg]         = React.useState(null);
  const [busy, setBusy]       = React.useState(false);

  const api = (path, body) => fetch('/api/v1/auth' + path, {
    method: 'POST', credentials: 'include',
    headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
    body: JSON.stringify(body || {}),
  });

  const startSetup = async () => {
    setMsg(null); setBusy(true);
    try {
      const r = await api('/totp/setup');
      if (r.status === 200) { setEnroll(await r.json()); setPhase('enrolling'); }
      else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Setup failed' });
    } finally { setBusy(false); }
  };

  const confirmEnable = async () => {
    setMsg(null); setBusy(true);
    try {
      const r = await api('/totp/enable', { code: code.trim() });
      const body = await r.json().catch(() => ({}));
      if (r.status === 200) {
        setRecovery(body.recovery_codes || []); setPhase('recovery');
        setEnabled(true); setCode('');
        window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: true };
      } else setMsg({ kind: 'err', text: body.error || 'Could not enable' });
    } finally { setBusy(false); }
  };

  const disable = async () => {
    setMsg(null); setBusy(true);
    try {
      const r = await api('/totp/disable', { password: disablePw });
      if (r.status === 204) {
        setEnabled(false); setShowDisable(false); setDisablePw(''); setPhase('idle');
        window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: false };
        setMsg({ kind: 'ok', text: 'Two-factor disabled' });
      } else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Could not disable' });
    } 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 }}>Two-factor authentication</h3>

      {/* Status line */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: phase === 'idle' ? 0 : 14 }}>
        <span className={`badge ${enabled ? 'success' : 'neutral'}`}>{enabled ? 'Enabled' : 'Disabled'}</span>
        <span style={{ fontSize: 12, color: 'var(--text-3)' }}>
          {enabled ? 'An authenticator code is required at sign-in.' : 'Add a time-based code from an authenticator app.'}
        </span>
        <span style={{ flex: 1 }} />
        {!enabled && phase === 'idle' && <button className="btn primary sm" disabled={busy} onClick={startSetup}>Set up</button>}
        {enabled && phase !== 'recovery' && !showDisable && <button className="btn ghost sm danger" onClick={() => setShowDisable(true)}>Disable</button>}
      </div>

      {/* Enrolling: show QR / secret + code field */}
      {phase === 'enrolling' && enroll && (
        <div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 16, alignItems: 'start' }}>
          <div>
            {enroll.qr
              ? <img src={enroll.qr} alt="TOTP QR code" style={{ width: 160, height: 160, borderRadius: 6, background: '#fff', padding: 6 }} />
              : <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Scan the secret below in your app.</div>}
          </div>
          <div>
            <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
              Scan the QR with Google Authenticator, Authy, or 1Password, or enter this secret manually:
            </div>
            <div className="mono" style={{ fontSize: 12, background: 'var(--bg-2)', padding: '6px 10px', borderRadius: 5, wordBreak: 'break-all', marginBottom: 12 }}>{enroll.secret}</div>
            <div className="field">
              <label className="field-label">Enter the 6-digit code to confirm</label>
              <input className="field-input mono" value={code} autoFocus
                     onChange={e => setCode(e.target.value)}
                     onKeyDown={e => { if (e.key === 'Enter' && code.trim()) confirmEnable(); }}
                     placeholder="123456" style={{ width: 140 }} />
            </div>
            <div style={{ marginTop: 10 }}>
              <button className="btn primary sm" disabled={busy || !code.trim()} onClick={confirmEnable}>Enable</button>
              <button className="btn ghost sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setEnroll(null); setCode(''); }}>Cancel</button>
            </div>
          </div>
        </div>
      )}

      {/* Recovery codes — shown exactly once */}
      {phase === 'recovery' && recovery && (
        <div>
          <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
            Save these recovery codes somewhere safe. Each works once if you lose your authenticator. They won't be shown again.
          </div>
          <div className="mono" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 13, marginBottom: 10 }}>
            {recovery.map(c => <div key={c}>{c}</div>)}
          </div>
          <button className="btn sm" onClick={() => navigator.clipboard && navigator.clipboard.writeText(recovery.join('\n'))}>Copy codes</button>
          <button className="btn primary sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setRecovery(null); }}>Done</button>
        </div>
      )}

      {/* Disable confirmation */}
      {showDisable && (
        <div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '160px 1fr auto auto', gap: 8, alignItems: 'end' }}>
          <div className="field" style={{ marginBottom: 0, gridColumn: '1 / 3' }}>
            <label className="field-label">Confirm your password to disable</label>
            <input className="field-input" type="password" value={disablePw} autoComplete="current-password"
                   onChange={e => setDisablePw(e.target.value)}
                   onKeyDown={e => { if (e.key === 'Enter' && disablePw) disable(); }} />
          </div>
          <button className="btn danger sm" disabled={busy || !disablePw} onClick={disable}>Disable 2FA</button>
          <button className="btn ghost sm" onClick={() => { setShowDisable(false); setDisablePw(''); }}>Cancel</button>
        </div>
      )}

      {msg && <div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>}
    </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 />
                <TotpSection />
                <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 (
    <>
      <StorageWarningBanner />
      <MountHealthStrip />
      <S3SettingsCard />
      <GrowingSettingsCard />
    </>
  );
}

// Set-once deployment warning. Storage paths are written into asset rows and
// the S3 layout at ingest time; changing them after assets exist orphans files
// and can corrupt the library's view of where masters/proxies live.
function StorageWarningBanner() {
  return (
    <div role="alert" style={{
      display: 'flex', alignItems: 'flex-start', gap: 12,
      padding: '14px 16px', marginBottom: 14, borderRadius: 10,
      border: '1px solid var(--danger)',
      background: 'color-mix(in srgb, var(--danger) 12%, transparent)',
    }}>
      <Icon name="alert" size={20} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }} />
      <div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em', lineHeight: 1.5, color: 'var(--text-1)' }}>
        WARNING: THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT.
        CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS.
        PLEASE USE WITH CAUTION.
      </div>
    </div>
  );
}

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 ? 'configured' : 'unreachable'} detail={g.error || ''} />
            : <span className="badge neutral">not configured</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 mount</span><span className="mono">{g.smb_mount || '·'}</span>
          <span>SMB (editors)</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 [pwd, setPwd] = React.useState('');        // new password to set; '' = leave unchanged
  const [pwdExists, setPwdExists] = React.useState(false);
  const [clearPwd, setClearPwd] = React.useState(false);
  const [saving, setSaving] = React.useState(false);
  const [msg, setMsg] = React.useState(null);

  React.useEffect(() => {
    window.ZAMPP_API.fetch('/settings/growing')
      .then(d => { setCfg(d); setPwdExists(!!d.growing_smb_password_exists); })
      .catch(() => setCfg({
        growing_path: '/growing', growing_smb_url: '', growing_smb_mount: '',
        growing_smb_username: '', growing_smb_vers: '3.0', growing_promote_after_seconds: '8',
      }));
  }, []);

  const save = () => {
    setSaving(true); setMsg(null);
    const body = {
      growing_path: cfg.growing_path,
      growing_smb_url: cfg.growing_smb_url,
      growing_smb_mount: cfg.growing_smb_mount,
      growing_smb_username: cfg.growing_smb_username,
      growing_smb_vers: cfg.growing_smb_vers,
      growing_promote_after_seconds: cfg.growing_promote_after_seconds,
    };
    if (clearPwd) body.growing_smb_password_clear = true;
    else if (pwd) body.growing_smb_password = pwd;
    window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(body) })
      .then(() => {
        setSaving(false); setMsg({ ok: true, text: 'Saved.' });
        if (clearPwd) { setPwdExists(false); setClearPwd(false); }
        else if (pwd) { setPwdExists(true); setPwd(''); }
      })
      .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 mountConfigured = !!(cfg.growing_smb_mount && cfg.growing_smb_mount.trim());

  return (
    <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Shared SMB landing zone. Enable per-recorder under Ingest → Recorders."
      tag={mountConfigured ? <span className="badge success">configured</span> : <span className="badge neutral">not configured</span>}>
      <form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
      <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 10, lineHeight: 1.5 }}>
        Growing-file mode is enabled <strong style={{ color: 'var(--text-2)' }}>per recorder</strong> (New recorder → Growing-files mode).
        These settings describe the SMB share that capture mounts and writes the live master to.
      </div>
      <SField label="SMB mount source (CIFS)">
        <input className="field-input mono" value={cfg.growing_smb_mount || ''} onChange={e => set('growing_smb_mount', e.target.value)} placeholder="//10.0.0.25/mam-growing" />
      </SField>
      <SField label="SMB username">
        <input className="field-input mono" value={cfg.growing_smb_username || ''} onChange={e => set('growing_smb_username', e.target.value)} placeholder="capture" autoComplete="off" />
      </SField>
      <SField label="SMB password">
        <input className="field-input mono" type="password" autoComplete="new-password"
          value={pwd}
          disabled={clearPwd}
          onChange={e => setPwd(e.target.value)}
          placeholder={pwdExists ? '•••••••• (saved — leave blank to keep)' : 'Enter SMB password'} />
        {pwdExists && (
          <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--text-3)', marginTop: 4 }}>
            <input type="checkbox" checked={clearPwd} onChange={e => { setClearPwd(e.target.checked); if (e.target.checked) setPwd(''); }} />
            Remove saved password
          </label>
        )}
      </SField>
      <SField label="CIFS protocol version">
        <input className="field-input mono" value={cfg.growing_smb_vers || ''} onChange={e => set('growing_smb_vers', e.target.value)} placeholder="3.0" />
      </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/&lt;vendor&gt;/</code> inside mam-api.
      </div>
      <SettingsMsg msg={msg} />
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        {SDK_VENDORS.map(v => (
          <SdkVendorRow
            key={v.id}
            vendor={v}
            status={(statuses && statuses[v.id]) || null}
            onDone={(text, ok = true) => { setMsg({ ok, text }); load(); }}
          />
        ))}
      </div>
    </SettingsCard>
  );
}

function SdkVendorRow({ vendor, status, onDone }) {
  const fileRef = React.useRef(null);
  const [uploading, setUploading] = React.useState(false);
  const [progress, setProgress] = React.useState(0);
  const [confirm, confirmModal] = window.useConfirm();

  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 = async () => {
    if (!(await confirm({ title: 'Remove staged SDK files?', message: 'Remove staged ' + vendor.name + ' SDK files?', confirmLabel: 'Remove' }))) 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)' }}>
      {confirmModal}
      <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 });
