← 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,'&').replace(/</g,'<').replace(/>/g,'>');
// 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>