Add searchable admin audit history

This commit is contained in:
Jonathan Sykes
2026-10-14 01:54:08 +08:00
parent 6bcaccbcaf
commit 301fab335f
9 changed files with 208 additions and 4 deletions

View File

@@ -115,6 +115,11 @@
pre { margin: 0; padding: 14px; border-radius: 14px; background: rgba(0,0,0,.35); border: 1px solid var(--line); overflow: auto; font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 60vh; }
.newtoken { margin-top: 12px; padding: 12px; border: 1px solid var(--ok); border-radius: 12px; background: rgba(74,222,128,.07); word-break: break-all; }
.src { font: 11px var(--mono); color: var(--text-dim); } .src.api, .src.auto { color: var(--warn); } .src.restore { color: var(--ok); }
.audit-filters { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; align-items:end; }
.audit-filters label { display:grid; gap:5px; color:var(--text-2); font-size:12px; }
.audit-tools { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.audit-detail { color:var(--text-2); font:12px/1.4 var(--mono); max-width:36ch; overflow-wrap:anywhere; }
@media(max-width:720px) { .audit-filters { grid-template-columns:repeat(2,minmax(0,1fr)); } }
/* ---------- upload card ---------- */
.sr-file { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; clip: rect(0 0 0 0); }
@@ -659,6 +664,24 @@
<div class="card"><div class="row"><h2 style="margin:0">Runs</h2><span class="grow"></span><button class="btn sm" type="button" id="deepRefresh">Refresh</button></div><p class="muted" id="deepIndexStatus"></p><div id="deepJobs"></div></div>
</section>
<section id="p-audit" class="panel">
<h1 class="page">Audit log</h1>
<p class="lead">A searchable record of admin changes, with the actor and target kept alongside each action.</p>
<div class="card">
<form id="auditFilterForm" class="audit-filters">
<label>Search<input class="field" id="auditSearch" type="search" maxlength="160" placeholder="target or detail" /></label>
<label>Actor<input class="field" id="auditActor" type="search" maxlength="120" placeholder="admin" /></label>
<label>Action<input class="field" id="auditAction" type="search" maxlength="120" placeholder="lyrics.save" /></label>
<label>From<input class="field" id="auditFrom" type="date" /></label>
<label>Through<input class="field" id="auditTo" type="date" /></label>
<div class="audit-tools"><button class="btn pri" type="submit">Filter</button><a class="btn sm" id="auditJson" href="/api/admin/audit?format=json">Export JSON</a><a class="btn sm" id="auditCsv" href="/api/admin/audit?format=csv">Export CSV</a></div>
<span class="grow"></span><span class="muted" id="auditCount" role="status" aria-live="polite"></span>
</form>
<div class="scroll"><table class="tbl" id="auditTable"></table></div>
<div class="row"><button class="btn" id="auditPrev" type="button">Previous</button><button class="btn" id="auditNext" type="button">Next</button><span class="grow"></span><span class="muted">50 events per page</span></div>
</div>
</section>
<section id="p-access" class="panel">
<h1 class="page">API access</h1>
<p class="lead">Tokens let scripts write shared lyrics and chapters. A token is shown once — store it in your secrets vault.</p>
@@ -715,9 +738,10 @@
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
'deep-search': '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5M8 10.5h5M10.5 8v5"/>',
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
};
const TABS = [['editor', 'Editor'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['access', 'API']];
const TABS = [['editor', 'Editor'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
let tab = 'editor';
function buildTabs() {
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
@@ -738,6 +762,7 @@
if (name === 'access') loadTokens();
if (name === 'analytics') loadAnalytics();
if (name === 'deep-search') loadDeepSearch();
if (name === 'audit') loadAudit();
window.scrollTo({ top: 0 });
}
function setBadge(n) {
@@ -889,6 +914,37 @@
const rows = (j.tokens || []).map((t) => `<tr><td>${esc(t.label)}</td><td class="muted">${when(t.createdAt)}</td><td class="muted">${t.lastUsedAt ? when(t.lastUsedAt) : 'never'}</td><td><button class="btn sm dng" data-revoke="${esc(t.id)}">Revoke</button></td></tr>`).join('');
$('tokenTable').innerHTML = rows ? `<tr><th>Label</th><th>Created</th><th>Last used</th><th></th></tr>${rows}` : '<tr><td class="muted">No tokens yet.</td></tr>';
}
/* ---------------- audit history ---------------- */
let auditOffset = 0;
function auditQuery(format = '') {
const p = new URLSearchParams(format ? {} : { limit: '50', offset: String(auditOffset) });
const search = $('auditSearch').value.trim(); if (search) p.set('q', search);
for (const [key, id] of [['actor', 'auditActor'], ['action', 'auditAction']]) {
const value = $(id).value.trim(); if (value) p.set(key, value);
}
for (const [key, id, end] of [['from', 'auditFrom', false], ['to', 'auditTo', true]]) {
const value = $(id).value;
if (value) p.set(key, String(Math.floor(new Date(`${value}T${end ? '23:59:59' : '00:00:00'}Z`).getTime() / 1000)));
}
if (format) p.set('format', format);
return p.toString();
}
async function loadAudit() {
const table = $('auditTable'); table.innerHTML = '<tr><td class="muted">Loading audit events…</td></tr>';
try {
const j = await api('/api/admin/audit?' + auditQuery());
if (!j.ok) throw Error(j.error || 'Audit log unavailable.');
const rows = j.events.map(event => `<tr><td>${when(event.ts)}</td><td>${esc(event.actor)}</td><td><code>${esc(event.action)}</code></td><td>${esc(event.target || '—')}</td><td class="audit-detail">${esc(JSON.stringify(event.detail || {}))}</td></tr>`).join('');
table.innerHTML = rows ? `<tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Details</th></tr>${rows}` : '<tr><td class="muted">No matching events.</td></tr>';
$('auditCount').textContent = `${j.total.toLocaleString()} events · ${j.events.length ? auditOffset + 1 : 0}–${auditOffset + j.events.length}`;
$('auditPrev').disabled = auditOffset === 0; $('auditNext').disabled = auditOffset + j.events.length >= j.total;
$('auditJson').href = '/api/admin/audit?' + auditQuery('json'); $('auditCsv').href = '/api/admin/audit?' + auditQuery('csv');
} catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
}
$('auditFilterForm').addEventListener('submit', event => { event.preventDefault(); auditOffset = 0; loadAudit(); });
$('auditPrev').addEventListener('click', () => { auditOffset = Math.max(0, auditOffset - 50); loadAudit(); });
$('auditNext').addEventListener('click', () => { auditOffset += 50; loadAudit(); });
$('tokenForm').addEventListener('submit', async (e) => {
e.preventDefault();
const label = $('tokenLabel').value.trim();