From d3376d7f4afc3046a85f39057bf172f11647d8fa Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 24 May 2026 22:17:04 +0200 Subject: [PATCH] feat(operator): expandable audit row reveals event metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows with a non-empty metadata object now show a chevron and become clickable. Clicking expands a detail row underneath that lists every metadata field — partner.updated/tenant.updated render their 'changes' array as a row of mono chips, everything else falls back to a generic key/value layout. Toggling is per-row, so the operator can open several at once when comparing actions. --- apps/operator/pages/audit.vue | 127 ++++++++++++++++++++++++++++------ 1 file changed, 104 insertions(+), 23 deletions(-) diff --git a/apps/operator/pages/audit.vue b/apps/operator/pages/audit.vue index a0afd34..bae9bb5 100644 --- a/apps/operator/pages/audit.vue +++ b/apps/operator/pages/audit.vue @@ -72,6 +72,26 @@ function shortIp(ip?: string) { return ip.replace(/^::ffff:/, '') } +// Row expansion — reveals metadata.changes + any other metadata keys for the +// row. Kept as a plain Set so toggling stays O(1) and the operator can have +// multiple rows open at once. +const expanded = ref(new Set()) +function toggleRow(id: string) { + if (expanded.value.has(id)) expanded.value.delete(id) + else expanded.value.add(id) + // Force reactivity — Set mutations aren't tracked by Vue directly. + expanded.value = new Set(expanded.value) +} +function hasDetails(e: AuditEvent): boolean { + return !!e.metadata && Object.keys(e.metadata).length > 0 +} +function formatMetaValue(v: unknown): string { + if (v === null || v === undefined) return '—' + if (Array.isArray(v)) return v.join(', ') + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + // ── Tamper-evidence (Phase 3) + cold-storage archives (Phase 4) ──────── interface VerifyReport { ok: boolean @@ -205,6 +225,7 @@ function fmtRelative(iso: string | null | undefined): string { + @@ -215,29 +236,59 @@ function fmtRelative(iso: string | null | undefined): string { - - - - - - - - - +
Time Actor Action
{{ fmtAbs(e.at) }} -
sys
- -
-
{{ e.actorEmail || 'system' }}
- {{ e.source }} -
-
{{ e.action }}{{ e.resourceName || e.resourceId || '—' }} - {{ e.tenantSlug }} - - {{ shortIp(e.actorIp) }} - - {{ e.outcome === 'failure' ? 'fail' : e.outcome === 'success' ? 'ok' : '—' }} - -
// no events match the current filters
@@ -414,6 +465,36 @@ th { th.r, td.r { text-align: right; } td { padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; } td.actor { display: flex; align-items: center; gap: 10px; } + +/* Expandable row: rows with metadata show a caret + become clickable. */ +.caret-col { width: 28px; padding-left: 12px; padding-right: 0; color: var(--text-mute); } +tr.row.expandable { cursor: pointer; } +tr.row.expandable:hover { background: var(--surface); } +tr.row.open .caret-col { color: var(--text); } + +tr.detail-row td { + background: var(--surface); + border-top: 0; + padding: 10px 16px 14px 16px; +} +.meta { + margin: 0; + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 14px; + align-items: baseline; +} +.meta dt { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; } +.meta dd { margin: 0; font-size: 12px; color: var(--text-dim); } +.changes { display: inline-flex; flex-wrap: wrap; gap: 6px; } +.change-chip { + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + font-size: 11px; + color: var(--text); +} .name { font-size: 12px; font-weight: 500; } .action { font-weight: 500; } .target { font-size: 12px; color: var(--text-dim); }