← back to Ventura Claw

server/public/admin.html

161 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Admin — VenturaClaw</title>
<link rel="stylesheet" href="/static/style.css" />
<style>
  .stat-card { padding: 18px; transition: border-color 200ms; }
  .stat-card.live { border-color: var(--good); }
  .stat-card.offline { border-color: var(--rule); opacity: 0.55; }
  .stat-num { font-family: var(--serif); font-size: 36px; font-weight: 500; line-height: 1.05; margin: 4px 0; }
  .stat-num em { color: var(--gold); font-style: italic; }
  .stat-sub { font-family: var(--mono); font-size: 10px; letter-spacing: .12em; color: var(--ink-mute); }
  .stat-sub.good { color: var(--good); }
  .stat-sub.warn { color: var(--warn); }
  .stat-sub.bad  { color: var(--bad); }
  .live-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
</style>
</head><body>
<header class="topbar">
  <div class="brand"><div class="logo-dot"></div><div><div class="brand-name">Ventura<em style="font-style:italic;color:var(--gold)">Claw</em></div><div class="brand-sub">Admin</div></div></div>
  <nav class="nav" style="display:flex;gap:6px;font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase">
    <a href="/admin"             style="padding:8px 14px;color:var(--gold);border-bottom:1px solid var(--gold)">Dashboard</a>
    <a href="/admin/users"       style="padding:8px 14px;color:var(--ink-soft)">Users</a>
    <a href="/admin/connectors"  style="padding:8px 14px;color:var(--ink-soft)">Connectors</a>
    <a href="/admin/approvals"   style="padding:8px 14px;color:var(--ink-soft)">Approvals <span id="pend" class="badge badge-pending" style="display:none;margin-left:6px">0</span></a>
    <a href="/admin/audit"       style="padding:8px 14px;color:var(--ink-soft)">Audit</a>
    <a href="/chat"              style="padding:8px 14px;color:var(--ink-soft)">Chat</a>
    <a href="#" id="logout"      style="padding:8px 14px;color:var(--ink-soft)">Sign out</a>
  </nav>
</header>

<main style="padding:24px 28px;max-width:1480px;margin:0 auto;display:flex;flex-direction:column;gap:20px">

  <section>
    <div class="brand-sub" style="margin-bottom:10px">SYSTEM</div>
    <div class="live-grid">
      <div class="glass stat-card"><div class="brand-sub">CONNECTORS</div><div class="stat-num" id="m-conn">…</div><div class="stat-sub" id="m-conn-sub">total catalog</div></div>
      <div class="glass stat-card"><div class="brand-sub">USERS</div><div class="stat-num" id="m-users">…</div><div class="stat-sub">admin + team</div></div>
      <div class="glass stat-card"><div class="brand-sub">PENDING APPROVALS</div><div class="stat-num" id="m-pend">…</div><div class="stat-sub" id="m-pend-sub">sensitive write actions</div></div>
      <div class="glass stat-card"><div class="brand-sub">AUDIT EVENTS</div><div class="stat-num" id="m-aud">…</div><div class="stat-sub" id="m-aud-sub">last 500</div></div>
    </div>
  </section>

  <section>
    <div class="brand-sub" style="margin-bottom:10px">LIVE FROM YOUR APIs</div>
    <div class="live-grid" id="live-tiles">
      <div class="glass stat-card" id="t-stripe"><div class="brand-sub">STRIPE BALANCE</div><div class="stat-num">…</div><div class="stat-sub">probing…</div></div>
      <div class="glass stat-card" id="t-cloudflare"><div class="brand-sub">CLOUDFLARE ZONES</div><div class="stat-num">…</div><div class="stat-sub">probing…</div></div>
      <div class="glass stat-card" id="t-slack"><div class="brand-sub">SLACK CHANNELS</div><div class="stat-num">…</div><div class="stat-sub">probing…</div></div>
      <div class="glass stat-card" id="t-mailchimp"><div class="brand-sub">MAILCHIMP LISTS</div><div class="stat-num">…</div><div class="stat-sub">probing…</div></div>
      <div class="glass stat-card" id="t-hubspot"><div class="brand-sub">HUBSPOT CONTACTS</div><div class="stat-num">…</div><div class="stat-sub">probing…</div></div>
      <div class="glass stat-card" id="t-airtable"><div class="brand-sub">AIRTABLE BASES</div><div class="stat-num">…</div><div class="stat-sub">probing…</div></div>
      <div class="glass stat-card" id="t-purelymail"><div class="brand-sub">PURELYMAIL DOMAINS</div><div class="stat-num">…</div><div class="stat-sub">probing…</div></div>
      <div class="glass stat-card" id="t-twilio"><div class="brand-sub">TWILIO MESSAGES</div><div class="stat-num">…</div><div class="stat-sub">probing…</div></div>
    </div>
  </section>

  <section>
    <div class="brand-sub" style="margin-bottom:10px">RECENT ACTIVITY</div>
    <div class="glass" style="padding:18px">
      <table><thead><tr><th>Time</th><th>Kind</th><th>Detail</th></tr></thead><tbody id="recent"></tbody></table>
    </div>
  </section>

</main>

<div class="footer">VenturaClaw</div>

<script>
const esc = s => String(s == null ? "" : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

// System counters (always show)
async function loadSystem() {
  const [conns, users, apps, aud] = await Promise.all([
    fetch('/api/connectors').then(r=>r.json()).catch(()=>({connectors:[]})),
    fetch('/api/admin/users').then(r=>r.json()).catch(()=>({users:[]})),
    fetch('/api/approvals').then(r=>r.json()).catch(()=>({approvals:[]})),
    fetch('/api/audit').then(r=>r.json()).catch(()=>({events:[],audit:[]})),
  ]);
  const live = (conns.connectors || []).filter(c => c.real_impl).length;
  document.getElementById('m-conn').innerHTML  = `${(conns.connectors||[]).length} <em>·${live} real</em>`;
  document.getElementById('m-conn-sub').textContent = `${(conns.connectors||[]).length} mocked + ${live} with real APIs`;
  document.getElementById('m-users').textContent = (users.users||[]).length;
  const pendList = (apps.approvals||[]).filter(a => a.status === 'pending');
  document.getElementById('m-pend').textContent = pendList.length;
  document.getElementById('m-pend-sub').className = 'stat-sub' + (pendList.length ? ' warn' : '');
  document.getElementById('m-pend-sub').textContent = pendList.length ? `${pendList.length} awaiting decision` : 'none waiting';
  if (pendList.length) { const p = document.getElementById('pend'); p.style.display='inline-block'; p.textContent = pendList.length; }
  const events = aud.events || aud.audit || [];
  document.getElementById('m-aud').textContent = aud.total || events.length;
  document.getElementById('m-aud-sub').textContent = events[0] ? `last: ${new Date(events[0].ts).toLocaleString()}` : 'no events yet';
  document.getElementById('recent').innerHTML = events.slice(0,15).map(e =>
    `<tr><td style="opacity:0.6;font-size:11px;white-space:nowrap">${new Date(e.ts).toLocaleString()}</td><td><code style="font-size:11px">${esc(e.kind)}</code></td><td style="opacity:0.8;font-size:12px">${esc(JSON.stringify({...e, ts:undefined, kind:undefined}).slice(0,160))}</td></tr>`
  ).join('') || '<tr><td colspan="3" style="text-align:center;opacity:0.5;padding:30px">no events yet</td></tr>';
}

// Live API tiles — runs the connector's read action via /api/connectors/:id/run
// and renders a 1-line summary. Sets tile to .live (green) or .offline (faint).
function setTile(id, num, sub, state) {
  const t = document.getElementById('t-' + id);
  if (!t) return;
  t.classList.remove('live', 'offline');
  if (state) t.classList.add(state);
  t.querySelector('.stat-num').textContent = num;
  t.querySelector('.stat-sub').textContent = sub;
}

async function probe(id, action, input, render) {
  try {
    const r = await fetch(`/api/connectors/${id}/run`, { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ action, input }) });
    const j = await r.json();
    if (!r.ok) { setTile(id, '—', j.error || 'offline', 'offline'); return; }
    render(j.result);
  } catch (e) { setTile(id, '—', e.message, 'offline'); }
}

async function loadLive() {
  // Each tile fires in parallel. Read-only actions skip the approval gate (per Phase 8 isWrite registry).
  Promise.all([
    probe('stripe',     'balance.get',         {}, r => {
      const usd = (r.available || []).find(b => b.currency === 'usd');
      const cents = usd?.amount ?? 0;
      setTile('stripe', `$${(cents/100).toFixed(2)}`, `${(r.pending||[]).length ? `+ ${(r.pending||[]).find(b=>b.currency==='usd')?.amount/100||0} pending` : 'available USD'}`, 'live');
    }),
    probe('cloudflare', 'zone.list',           {}, r => setTile('cloudflare', (r||[]).length, `zones managed`, 'live')),
    probe('slack',      'channels.list',       {}, r => {
      const channels = r.channels || [];
      setTile('slack', channels.length, `public + private channels`, 'live');
    }),
    probe('mailchimp',  'lists.list',          {}, r => {
      const lists = r.lists || [];
      const total = lists.reduce((n, l) => n + (l.stats?.member_count || 0), 0);
      setTile('mailchimp', lists.length, `lists · ${total.toLocaleString()} subs`, 'live');
    }),
    probe('hubspot',    'contacts.list',       { limit: 1 }, r => {
      const total = r.total ?? (r.results||[]).length ?? '—';
      setTile('hubspot', total === '—' ? '—' : total.toLocaleString(), `total contacts`, 'live');
    }),
    probe('airtable',   'bases.list',          {}, r => setTile('airtable', (r.bases||[]).length, `bases accessible`, 'live')),
    probe('purelymail', 'domains.list',        {}, r => {
      const domains = r.result || r.domains || [];
      setTile('purelymail', Array.isArray(domains) ? domains.length : '—', `email domains`, 'live');
    }),
    probe('twilio',     'messages.list',       { limit: 1 }, r => {
      const msgs = r.messages || [];
      setTile('twilio', msgs.length, msgs[0] ? `last: ${new Date(msgs[0].date_sent).toLocaleDateString()}` : 'no messages yet', 'live');
    }),
  ]);
}

(async () => {
  await loadSystem();
  loadLive();   // fires in background; tiles update as each returns
})();

document.getElementById('logout').addEventListener('click', async e => {
  e.preventDefault(); await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login';
});
</script>
</body></html>