feat(operator): expandable audit row reveals event metadata

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.
This commit is contained in:
Ronni Baslund
2026-05-24 22:17:04 +02:00
parent b7cddcc6d7
commit d3376d7f4a
+82 -1
View File
@@ -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<string>())
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 {
<table v-if="allEvents.length">
<thead>
<tr>
<th class="caret-col"></th>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
@@ -215,7 +236,18 @@ function fmtRelative(iso: string | null | undefined): string {
</tr>
</thead>
<tbody>
<tr v-for="e in allEvents" :key="e._id">
<template v-for="e in allEvents" :key="e._id">
<tr
:class="['row', { expandable: hasDetails(e), open: expanded.has(e._id) }]"
@click="hasDetails(e) && toggleRow(e._id)"
>
<td class="caret-col">
<UiIcon
v-if="hasDetails(e)"
:name="expanded.has(e._id) ? 'chevDown' : 'chevRight'"
:size="12"
/>
</td>
<td><Mono>{{ fmtAbs(e.at) }}</Mono></td>
<td class="actor">
<div v-if="e.actorType === 'system'" class="sys">sys</div>
@@ -238,6 +270,25 @@ function fmtRelative(iso: string | null | undefined): string {
</Badge>
</td>
</tr>
<tr v-if="expanded.has(e._id) && hasDetails(e)" class="detail-row">
<td></td>
<td colspan="7">
<dl class="meta">
<template v-for="(value, key) in e.metadata" :key="key">
<dt><Mono dim>{{ key }}</Mono></dt>
<dd>
<span v-if="key === 'changes' && Array.isArray(value)" class="changes">
<Mono v-for="field in value as string[]" :key="field" class="change-chip">
{{ field }}
</Mono>
</span>
<Mono v-else>{{ formatMetaValue(value) }}</Mono>
</dd>
</template>
</dl>
</td>
</tr>
</template>
</tbody>
</table>
<div v-else class="empty"><Mono dim>// no events match the current filters</Mono></div>
@@ -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); }