← back to Ventura Claw

server/public/admin-audit.html

161 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Audit — VenturaClaw</title>
<link rel="stylesheet" href="/static/style.css" />
<style>
  .filter-bar { padding: 14px 18px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
  .filter-bar input, .filter-bar select { font-family: var(--mono); font-size: 11px; }
  .audit-row { padding: 10px 14px; border: 1px solid var(--rule); border-radius: 2px; margin-bottom: 4px; display: grid; grid-template-columns: 160px 180px 1fr; gap: 14px; align-items: baseline; transition: background 200ms; }
  .audit-row:hover { background: rgba(244,241,234,0.04); }
  .audit-row.expanded { grid-template-columns: 160px 180px 1fr; background: rgba(244,241,234,0.04); }
  .audit-row.expanded .audit-detail { white-space: pre-wrap; max-height: 400px; overflow-y: auto; }
  .audit-time { font-family: var(--mono); font-size: 10px; color: var(--ink-mute); white-space: nowrap; }
  .audit-kind { font-family: var(--mono); font-size: 11px; }
  .audit-kind.error, .audit-kind.failed, .audit-kind.blocked { color: var(--bad); }
  .audit-kind.ok, .audit-kind.passed, .audit-kind.connected, .audit-kind.refreshed { color: var(--good); }
  .audit-kind.pending, .audit-kind.queued, .audit-kind.override { color: var(--warn); }
  .audit-detail { font-family: var(--mono); font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
  .stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
  .stat-chip { padding: 5px 12px; border: 1px solid var(--rule); border-radius: 999px; font-family: var(--mono); font-size: 10px; letter-spacing: .12em; cursor: pointer; }
  .stat-chip.active { color: var(--gold); border-color: var(--gold); background: rgba(212,160,74,0.06); }
</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">Audit Log</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(--ink-soft)">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</a>
    <a href="/admin/audit"      style="padding:8px 14px;color:var(--gold);border-bottom:1px solid var(--gold)">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">

  <div class="glass filter-bar">
    <input id="search" placeholder="search by user, kind, content…" style="flex:1;min-width:240px" />
    <select id="kind-filter">
      <option value="">all kinds</option>
    </select>
    <select id="window-filter">
      <option value="all">all time</option>
      <option value="1h">last hour</option>
      <option value="24h">last 24h</option>
      <option value="7d">last 7 days</option>
    </select>
    <button class="btn btn-ghost" id="export-csv">Export CSV</button>
  </div>

  <div class="stats" id="stats"></div>

  <div id="rows"></div>

  <div style="text-align:center;padding:14px;font-family:var(--mono);font-size:10px;color:var(--ink-mute)" id="footer-stats"></div>
</main>

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

<script>
const esc = s => String(s == null ? "" : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let EVENTS = [];
let SEARCH = '', KIND = '', WINDOW = 'all';
const TONE = e => {
  const k = (e.kind||'').toLowerCase();
  if (k.includes('error') || k.includes('failed') || k.includes('blocked')) return 'error';
  if (k.includes('ok') || k.includes('passed') || k.includes('connected') || k.includes('refreshed')) return 'ok';
  if (k.includes('pending') || k.includes('queued') || k.includes('override')) return 'pending';
  return '';
};

async function refresh() {
  const r = await fetch('/api/audit?limit=1000');
  const j = await r.json();
  EVENTS = j.events || j.audit || [];
  // Populate kind filter
  const kinds = [...new Set(EVENTS.map(e => e.kind))].sort();
  const sel = document.getElementById('kind-filter');
  if (sel.options.length <= 1) sel.innerHTML += kinds.map(k => `<option>${esc(k)}</option>`).join('');
  render();
}

function withinWindow(e) {
  if (WINDOW === 'all') return true;
  const t = new Date(e.ts).getTime();
  const ms = WINDOW === '1h' ? 3600e3 : WINDOW === '24h' ? 86400e3 : 7*86400e3;
  return Date.now() - t < ms;
}

function render() {
  const filtered = EVENTS.filter(e => {
    if (KIND && e.kind !== KIND) return false;
    if (!withinWindow(e)) return false;
    if (SEARCH) {
      const hay = JSON.stringify(e).toLowerCase();
      if (!hay.includes(SEARCH)) return false;
    }
    return true;
  });

  // Stats: top 6 kinds by count
  const counts = {};
  for (const e of filtered) counts[e.kind] = (counts[e.kind]||0) + 1;
  const top = Object.entries(counts).sort((a,b) => b[1]-a[1]).slice(0, 8);
  document.getElementById('stats').innerHTML = top.map(([k,n]) =>
    `<span class="stat-chip ${KIND===k?'active':''}" data-kind="${esc(k)}"><strong>${n}</strong> ${esc(k)}</span>`
  ).join('');
  document.querySelectorAll('[data-kind]').forEach(c => c.addEventListener('click', () => {
    KIND = (KIND === c.dataset.kind) ? '' : c.dataset.kind;
    document.getElementById('kind-filter').value = KIND;
    render();
  }));

  document.getElementById('rows').innerHTML = filtered.slice(0, 500).map((e,i) => {
    const tone = TONE(e);
    const detail = JSON.stringify({...e, ts:undefined, kind:undefined});
    return `<div class="audit-row" data-idx="${i}">
      <div class="audit-time">${new Date(e.ts).toLocaleString()}</div>
      <div class="audit-kind ${tone}">${esc(e.kind)}</div>
      <div class="audit-detail">${esc(detail.slice(0, 200))}</div>
    </div>`;
  }).join('') || '<div style="text-align:center;padding:60px;color:var(--ink-mute)">no events match</div>';

  document.querySelectorAll('.audit-row').forEach(r => r.addEventListener('click', () => {
    const e = filtered[parseInt(r.dataset.idx, 10)];
    const det = r.querySelector('.audit-detail');
    if (r.classList.contains('expanded')) {
      r.classList.remove('expanded');
      det.textContent = JSON.stringify({...e, ts:undefined, kind:undefined}).slice(0, 200);
    } else {
      r.classList.add('expanded');
      det.textContent = JSON.stringify(e, null, 2);
    }
  }));

  document.getElementById('footer-stats').textContent = `showing ${filtered.length} of ${EVENTS.length} total events`;
}

document.getElementById('search').addEventListener('input', e => { SEARCH = e.target.value.toLowerCase(); render(); });
document.getElementById('kind-filter').addEventListener('change', e => { KIND = e.target.value; render(); });
document.getElementById('window-filter').addEventListener('change', e => { WINDOW = e.target.value; render(); });
document.getElementById('export-csv').addEventListener('click', () => {
  const rows = [['ts','kind','detail']].concat(EVENTS.map(e => [e.ts, e.kind, JSON.stringify({...e, ts:undefined, kind:undefined})]));
  const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g,'""')}"`).join(',')).join('\n');
  const blob = new Blob([csv], { type: 'text/csv' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = `ventura-claw-audit-${new Date().toISOString().slice(0,10)}.csv`;
  a.click();
});
document.getElementById('logout').addEventListener('click', async e => {
  e.preventDefault(); await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login';
});

refresh();
setInterval(refresh, 60_000); // auto-refresh every 60s
</script>
</body></html>