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