Newer
Older
navi-1 / admin / index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Navi — Admin</title>
  <link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.0.3/src/regular/style.css">
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    :root {
      --bg: #0e0e0e; --bg2: #161616; --bg3: #1e1e1e; --bg4: #222;
      --border: #252525; --border2: #2e2e2e;
      --text: #d4d4d4; --text2: #888; --text3: #555;
      --accent: #4ec9b0;
      --danger: #f48771;
    }
    html, body { height: 100%; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      font-size: 13px;
      background: var(--bg);
      color: var(--text);
      display: flex;
      flex-direction: column;
      height: 100vh;
      overflow: hidden;
    }
    header {
      flex-shrink: 0;
      background: var(--bg2);
      border-bottom: 1px solid var(--border);
      padding: 0 14px;
      display: flex;
      align-items: center;
      gap: 10px;
    }
    .logo {
      font-size: 11px; color: var(--text3); letter-spacing: .1em;
      text-transform: uppercase; white-space: nowrap;
      align-self: center; padding: 10px 0;
    }
    .logo b { color: var(--accent); }
    .tabs {
      display: flex; align-items: stretch; gap: 2px; margin-left: 10px;
    }
    .tab {
      padding: 0 14px; font-family: inherit; font-size: 12px;
      color: var(--text3); background: none; border: none;
      border-bottom: 2px solid transparent; cursor: pointer;
      transition: color .15s, border-color .15s; line-height: 40px;
    }
    .tab:hover { color: var(--text2); }
    .tab.active { color: var(--text); border-bottom-color: var(--accent); }
    .header-right {
      margin-left: auto;
      display: flex; align-items: center; gap: 12px;
    }
    .header-right a {
      color: var(--text2); text-decoration: none; font-size: 12px;
    }
    .header-right a:hover { color: var(--text); }
    main { flex: 1; overflow-y: auto; padding: 14px 16px; }
    .pane { display: none; }
    .pane.active { display: block; }

    /* Tables */
    table {
      width: 100%; border-collapse: collapse; font-size: 13px;
    }
    th, td {
      padding: 8px 12px; border-bottom: 1px solid var(--border);
      text-align: left; color: var(--text);
    }
    th {
      background: var(--bg2); font-weight: 600; color: #fff;
      position: sticky; top: 0; z-index: 1;
    }
    tr:hover td { background: var(--bg2); }
    .mono { font-family: monospace; font-size: 11px; color: var(--text2); }
    .dim { color: var(--text3); }
    .right { text-align: right; }

    /* Controls */
    input, select, button {
      font-family: inherit; font-size: 12px;
      background: var(--bg3); border: 1px solid var(--border2);
      color: var(--text); border-radius: 4px; outline: none;
    }
    input, select { padding: 6px 10px; }
    button { padding: 6px 12px; cursor: pointer; }
    button:hover { background: var(--bg4); border-color: #555; }
    button.danger { background: #3a1e1e; border-color: #522; }
    button.danger:hover { background: #522; }
    .toolbar {
      display: flex; align-items: center; gap: 12px;
      margin-bottom: 12px; flex-wrap: wrap;
    }
    .search-input {
      flex: 1; max-width: 320px;
      background: var(--bg3); color: var(--text);
      border: 1px solid var(--border2); padding: 6px 10px;
    }

    /* Switch */
    .switch {
      position: relative; display: inline-block;
      width: 40px; height: 22px;
    }
    .switch input { opacity: 0; width: 0; height: 0; }
    .slider {
      position: absolute; cursor: pointer;
      top: 0; left: 0; right: 0; bottom: 0;
      background: #444; transition: .2s; border-radius: 22px;
    }
    .slider:before {
      position: absolute; content: "";
      height: 16px; width: 16px; left: 3px; bottom: 3px;
      background: white; transition: .2s; border-radius: 50%;
    }
    input:checked + .slider { background: var(--accent); }
    input:checked + .slider:before { transform: translateX(18px); }

    /* Drawer */
    .drawer-overlay {
      position: fixed; inset: 0;
      background: rgba(0,0,0,0.5); z-index: 200;
      display: flex; justify-content: flex-end;
    }
    .drawer {
      width: 500px; background: var(--bg2);
      padding: 1rem; overflow: auto;
      border-left: 1px solid var(--border);
    }
    .drawer pre {
      font-size: 12px; color: var(--text);
      white-space: pre-wrap; word-break: break-word;
    }
    .drawer h3 { margin-bottom: 10px; color: #fff; }

    /* Group */
    .group-title {
      margin: 12px 0 8px; color: #fff; font-size: 14px;
      text-transform: capitalize;
    }
    .value-cell {
      max-width: 400px; overflow: hidden;
      text-overflow: ellipsis; white-space: nowrap;
    }

    .placeholder {
      text-align: center; color: var(--text3);
      padding: 80px 0; font-size: 13px;
    }
    .error-msg { color: var(--danger); padding: 12px; }

    /* Sortable headers */
    th.sortable { cursor: pointer; user-select: none; }
    th.sortable::after { content: ' ⇅'; color: var(--text3); font-size: 10px; }
    th.sortable.sort-asc::after { content: ' ▲'; color: var(--accent); }
    th.sortable.sort-desc::after { content: ' ▼'; color: var(--accent); }

    /* Pagination */
    .pagination-btn {
      padding: 4px 10px; font-size: 12px; min-width: 32px;
      background: var(--bg3); border: 1px solid var(--border2);
      color: var(--text); border-radius: 4px; cursor: pointer;
    }
    .pagination-btn:hover:not(:disabled) { background: var(--bg4); }
    .pagination-btn:disabled { opacity: 0.4; cursor: not-allowed; }
    .pagination-info { color: var(--text2); font-size: 12px; margin: 0 8px; }

    /* Scrollbar */
    ::-webkit-scrollbar { width: 6px; }
    ::-webkit-scrollbar-track { background: transparent; }
    ::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
    ::-webkit-scrollbar-thumb:hover { background: #444; }
  </style>
</head>
<body>

<header>
  <div class="logo"><b>NAVI</b> ADMIN</div>
  <div class="tabs">
    <button class="tab active" data-tab="users">Users</button>
    <button class="tab" data-tab="sessions">Sessions</button>
    <button class="tab" data-tab="memory">Memory</button>
    <button class="tab" data-tab="profiles">Profiles</button>
  </div>
  <div class="header-right">
    <a href="/debug">Debug</a>
    <a href="/debug/eval">Eval</a>
    <a href="/">App</a>
  </div>
</header>

<main>
  <!-- Users -->
  <div id="pane-users" class="pane active">
    <div id="users-content"><div class="placeholder">Loading users…</div></div>
  </div>

  <!-- Sessions -->
  <div id="pane-sessions" class="pane">
    <div class="toolbar">
      <input id="sess-search" class="search-input" placeholder="Search session ID, name, user or profile…">
      <span id="sess-count" class="dim"></span>
    </div>
    <div id="sessions-content"><div class="placeholder">Loading sessions…</div></div>
    <div id="sess-pagination" class="toolbar" style="justify-content:center; margin-top:8px;"></div>
  </div>

  <!-- Memory -->
  <div id="pane-memory" class="pane">
    <div class="toolbar">
      <input id="mem-search" class="search-input" placeholder="Search key, value or category…">
      <span id="mem-count" class="dim"></span>
    </div>
    <div id="memory-content"><div class="placeholder">Loading memory…</div></div>
  </div>

  <!-- Profiles -->
  <div id="pane-profiles" class="pane">
    <div id="profiles-content"><div class="placeholder">Loading profiles…</div></div>
  </div>
</main>

<script>
'use strict';

// ── tiny utils ──────────────────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const fmtDate = (d) => d ? new Date(d).toLocaleString() : '—';
const esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

const api = {
  async request(method, path, body) {
    const opts = { method, credentials: 'include', headers: {} };
    if (body !== undefined) {
      opts.headers['Content-Type'] = 'application/json';
      opts.body = JSON.stringify(body);
    }
    const r = await fetch(path, opts);
    if (!r.ok) {
      const txt = await r.text().catch(() => '');
      throw new Error(`${method} ${path} → ${r.status}: ${txt}`);
    }
    if (r.status === 204) return null;
    return r.json();
  },
  get(path) { return this.request('GET', path); },
  patch(path, body) { return this.request('PATCH', path, body); },
  del(path) { return this.request('DELETE', path); },

  users()    { return this.get('/admin/users'); },
  sessions(qs='') { return this.get('/admin/sessions' + (qs ? '?' + qs : '')); },
  memory()   { return this.get('/admin/memory'); },
  profiles() { return this.get('/admin/profiles'); },
  session(id){ return this.get(`/admin/sessions/${id}`); },
  delSession(id){ return this.del(`/admin/sessions/${id}`); },
  userSessions(id){ return this.get(`/admin/users/${id}/sessions`); },
  updateRole(id, role){ return this.patch(`/admin/users/${id}/role`, { role }); },
  updateProfile(id, isAdminOnly){ return this.patch(`/admin/profiles/${id}/availability`, { is_admin_only: isAdminOnly }); },
};

// ── tabs ─────────────────────────────────────────────────────────────────
function switchTab(tab) {
  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
  document.querySelectorAll('.pane').forEach(p => p.classList.toggle('active', p.id === `pane-${tab}`));
  history.replaceState(null, '', `#${tab}`);
  if (tab === 'users' && !state.usersLoaded) loadUsers();
  if (tab === 'sessions' && !state.sessionsLoaded) loadSessions();
  if (tab === 'memory' && !state.memoryLoaded) loadMemory();
  if (tab === 'profiles' && !state.profilesLoaded) loadProfiles();
}

document.querySelectorAll('.tab').forEach(btn =>
  btn.addEventListener('click', () => switchTab(btn.dataset.tab))
);

const state = { usersLoaded: false, sessionsLoaded: false, memoryLoaded: false, profilesLoaded: false };

// ── Users ────────────────────────────────────────────────────────────────
async function loadUsers() {
  const el = $('#users-content');
  try {
    const users = await api.users();
    state.usersLoaded = true;
    el.innerHTML = renderUsers(users);
  } catch (err) {
    el.innerHTML = `<div class="error-msg">${esc(err.message)}</div>`;
  }
}

function renderUsers(users) {
  if (!users.length) return '<div class="placeholder">No users.</div>';
  let html = '<table><thead><tr>';
  ['ID','Email','Display Name','Role','Permissions','Created','Actions'].forEach(h => html += `<th>${h}</th>`);
  html += '</tr></thead><tbody>';
  for (const u of users) {
    const perms = (() => { try { const a = JSON.parse(u.permissions); return Array.isArray(a) && a.length ? a.join(', ') : '—'; } catch { return '—'; } })();
    html += `<tr>
      <td class="mono">${esc(u.id)}</td>
      <td>${esc(u.email)}</td>
      <td>${esc(u.display_name || '—')}</td>
      <td><select onchange="onRoleChange('${esc(u.id)}', this.value)">
        <option value="user" ${u.role==='user'?'selected':''}>user</option>
        <option value="admin" ${u.role==='admin'?'selected':''}>admin</option>
      </select></td>
      <td>${esc(perms)}</td>
      <td>${fmtDate(u.created_at)}</td>
      <td><button onclick="showUserDetail('${esc(u.id)}')">Detail</button></td>
    </tr>`;
  }
  html += '</tbody></table>';
  return html;
}

window.onRoleChange = async (id, role) => {
  try { await api.updateRole(id, role); } catch (err) { alert(err.message); }
};
window.showUserDetail = async (id) => {
  try {
    const u = (await api.users()).find(x => x.id === id);
    showDrawer('User Detail', JSON.stringify(u, null, 2));
  } catch (err) { alert(err.message); }
};

// ── Sessions ───────────────────────────────────────────────────────────────
const sessState = { limit: 50, offset: 0, search: '', sortBy: 'last_active', sortOrder: 'desc', total: 0, items: [] };

async function loadSessions() {
  const el = $('#sessions-content');
  const countEl = $('#sess-count');
  try {
    const params = new URLSearchParams();
    params.set('limit', String(sessState.limit));
    params.set('offset', String(sessState.offset));
    if (sessState.search) params.set('search', sessState.search);
    params.set('sort_by', sessState.sortBy);
    params.set('sort_order', sessState.sortOrder);
    const data = await api.sessions(params.toString());
    state.sessionsLoaded = true;
    sessState.total = data.total || 0;
    sessState.items = data.items || [];
    el.innerHTML = renderSessionsTable(sessState.items);
    countEl.textContent = `${sessState.total} total`;
    renderSessionPagination();
  } catch (err) {
    el.innerHTML = `<div class="error-msg">${esc(err.message)}</div>`;
    countEl.textContent = '';
  }
}

const SESSION_COLUMNS = [
  { key: 'session_id', label: 'Session ID' },
  { key: 'user_id', label: 'User' },
  { key: 'profile_id', label: 'Profile' },
  { key: 'name', label: 'Name' },
  { key: 'message_count', label: 'Messages' },
  { key: 'pinned', label: 'Pinned' },
  { key: 'last_active', label: 'Last Active' },
  { key: 'actions', label: 'Actions', sortable: false },
];

function renderSessionsTable(items) {
  if (!items.length) return '<div class="placeholder">No sessions.</div>';
  let html = '<table><thead><tr>';
  for (const col of SESSION_COLUMNS) {
    const isSorted = sessState.sortBy === col.key;
    const cls = col.sortable !== false ? `sortable ${isSorted ? 'sort-' + sessState.sortOrder : ''}` : '';
    html += `<th class="${cls}" onclick="${col.sortable !== false ? `onSessionSort('${esc(col.key)}')` : ''}">${esc(col.label)}</th>`;
  }
  html += '</tr></thead><tbody>';
  for (const s of items) {
    html += `<tr>
      <td class="mono">${esc(s.session_id)}</td>
      <td class="mono">${esc(s.user_id || '—')}</td>
      <td>${esc(s.profile_id)}</td>
      <td>${esc(s.name || '—')}</td>
      <td class="right">${s.message_count}</td>
      <td>${s.pinned ? 'Yes' : 'No'}</td>
      <td>${fmtDate(s.last_active)}</td>
      <td>
        <button onclick="showSessionDetail('${esc(s.session_id)}')">Detail</button>
        <button class="danger" onclick="deleteSession('${esc(s.session_id)}')">Delete</button>
      </td>
    </tr>`;
  }
  html += '</tbody></table>';
  return html;
}

function renderSessionPagination() {
  const el = $('#sess-pagination');
  const start = sessState.total ? sessState.offset + 1 : 0;
  const end = Math.min(sessState.offset + sessState.limit, sessState.total);
  const hasPrev = sessState.offset > 0;
  const hasNext = sessState.offset + sessState.limit < sessState.total;
  el.innerHTML = `
    <button class="pagination-btn" onclick="changeSessionPage(-1)" ${hasPrev ? '' : 'disabled'}>Prev</button>
    <span class="pagination-info">${start}–${end} of ${sessState.total}</span>
    <button class="pagination-btn" onclick="changeSessionPage(1)" ${hasNext ? '' : 'disabled'}>Next</button>
    <select class="pagination-btn" onchange="setSessionLimit(this.value)" style="padding:4px 6px; min-width:60px;">
      <option value="10" ${sessState.limit===10?'selected':''}>10</option>
      <option value="25" ${sessState.limit===25?'selected':''}>25</option>
      <option value="50" ${sessState.limit===50?'selected':''}>50</option>
      <option value="100" ${sessState.limit===100?'selected':''}>100</option>
    </select>
  `;
}

window.changeSessionPage = (delta) => {
  sessState.offset = Math.max(0, sessState.offset + delta * sessState.limit);
  loadSessions();
};
window.setSessionLimit = (val) => {
  sessState.limit = parseInt(val, 10);
  sessState.offset = 0;
  loadSessions();
};
window.onSessionSort = (col) => {
  if (sessState.sortBy === col) {
    sessState.sortOrder = sessState.sortOrder === 'asc' ? 'desc' : 'asc';
  } else {
    sessState.sortBy = col;
    sessState.sortOrder = 'desc';
  }
  sessState.offset = 0;
  loadSessions();
};

$('#sess-search').addEventListener('input', debounce(() => {
  sessState.search = $('#sess-search').value.trim();
  sessState.offset = 0;
  loadSessions();
}, 300));

function debounce(fn, ms) {
  let t;
  return () => { clearTimeout(t); t = setTimeout(fn, ms); };
}

window.showSessionDetail = async (id) => {
  try {
    const s = await api.session(id);
    showDrawer('Session Detail', JSON.stringify(s, null, 2));
  } catch (err) { alert(err.message); }
};
window.deleteSession = async (id) => {
  if (!confirm(`Delete session ${id}?`)) return;
  try { await api.delSession(id); loadSessions(); } catch (err) { alert(err.message); }
};

// ── Memory ─────────────────────────────────────────────────────────────────
async function loadMemory() {
  const el = $('#memory-content');
  try {
    const data = await api.memory();
    state.memoryLoaded = true;
    state.memoryFacts = data.facts || [];
    renderMemory(state.memoryFacts);
  } catch (err) {
    el.innerHTML = `<div class="error-msg">${esc(err.message)}</div>`;
  }
}

function renderMemory(facts) {
  const el = $('#memory-content');
  const q = ($('#mem-search').value || '').trim().toLowerCase();
  let filtered = facts;
  if (q) {
    filtered = facts.filter(f =>
      (f.key||'').toLowerCase().includes(q) ||
      (f.value||'').toLowerCase().includes(q) ||
      (f.category||'').toLowerCase().includes(q)
    );
  }
  $('#mem-count').textContent = `${filtered.length} / ${facts.length} facts`;

  if (!filtered.length) { el.innerHTML = '<div class="placeholder">No facts.</div>'; return; }

  const groups = {};
  for (const f of filtered) {
    const cat = f.category || 'other';
    if (!groups[cat]) groups[cat] = [];
    groups[cat].push(f);
  }

  let html = '';
  for (const cat of Object.keys(groups).sort()) {
    html += `<h4 class="group-title">${esc(cat)} (${groups[cat].length})</h4>`;
    html += '<table><thead><tr>';
    ['Key','Value','Source','Confidence','Updated'].forEach(h => html += `<th>${h}</th>`);
    html += '</tr></thead><tbody>';
    for (const f of groups[cat]) {
      html += `<tr>
        <td>${esc(f.key)}</td>
        <td class="value-cell">${esc(f.value)}</td>
        <td>${esc(f.source)}</td>
        <td>${f.confidence}</td>
        <td>${fmtDate(f.updated_at)}</td>
      </tr>`;
    }
    html += '</tbody></table>';
  }
  el.innerHTML = html;
}

$('#mem-search').addEventListener('input', () => renderMemory(state.memoryFacts));

// ── Profiles ──────────────────────────────────────────────────────────────
async function loadProfiles() {
  const el = $('#profiles-content');
  try {
    const profiles = await api.profiles();
    state.profilesLoaded = true;
    el.innerHTML = renderProfiles(profiles);
  } catch (err) {
    el.innerHTML = `<div class="error-msg">${esc(err.message)}</div>`;
  }
}

function renderProfiles(profiles) {
  if (!profiles.length) return '<div class="placeholder">No profiles.</div>';
  let html = '<table><thead><tr>';
  ['ID','Name','Description','Admin Only','Actions'].forEach(h => html += `<th>${h}</th>`);
  html += '</tr></thead><tbody>';
  for (const p of profiles) {
    html += `<tr>
      <td class="mono">${esc(p.id)}</td>
      <td>${esc(p.name)}</td>
      <td>${esc(p.description)}</td>
      <td>
        <label class="switch">
          <input type="checkbox" ${p.is_admin_only ? 'checked' : ''} onchange="toggleProfile('${esc(p.id)}', this.checked)">
          <span class="slider"></span>
        </label>
      </td>
      <td>${p.is_admin_only ? 'Yes' : 'No'}</td>
    </tr>`;
  }
  html += '</tbody></table>';
  return html;
}

window.toggleProfile = async (id, value) => {
  try { await api.updateProfile(id, value); loadProfiles(); } catch (err) { alert(err.message); }
};

// ── Drawer ─────────────────────────────────────────────────────────────────
function showDrawer(title, content) {
  const existing = document.querySelector('.drawer-overlay');
  if (existing) existing.remove();

  const overlay = document.createElement('div');
  overlay.className = 'drawer-overlay';
  overlay.innerHTML = `
    <div class="drawer">
      <h3>${esc(title)}</h3>
      <pre>${esc(content)}</pre>
      <button onclick="this.closest('.drawer-overlay').remove()">Close</button>
    </div>`;
  overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
  document.body.appendChild(overlay);
}

// ── boot ───────────────────────────────────────────────────────────────────
const hash = location.hash.slice(1);
const initialTab = ['users','sessions','memory','profiles'].includes(hash) ? hash : 'users';
if (initialTab !== 'users') switchTab(initialTab);
else loadUsers();
</script>

</body>
</html>