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:
+104
-23
@@ -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,29 +236,59 @@ function fmtRelative(iso: string | null | undefined): string {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in allEvents" :key="e._id">
|
||||
<td><Mono>{{ fmtAbs(e.at) }}</Mono></td>
|
||||
<td class="actor">
|
||||
<div v-if="e.actorType === 'system'" class="sys">sys</div>
|
||||
<Avatar v-else :name="e.actorEmail || '?'" :size="22" />
|
||||
<div>
|
||||
<div class="name">{{ e.actorEmail || 'system' }}</div>
|
||||
<Mono dim>{{ e.source }}</Mono>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono class="action">{{ e.action }}</Mono></td>
|
||||
<td><span class="target">{{ e.resourceName || e.resourceId || '—' }}</span></td>
|
||||
<td>
|
||||
<Mono v-if="e.tenantSlug">{{ e.tenantSlug }}</Mono>
|
||||
<Mono v-else dim>—</Mono>
|
||||
</td>
|
||||
<td><Mono dim>{{ shortIp(e.actorIp) }}</Mono></td>
|
||||
<td class="r">
|
||||
<Badge :tone="tone(e)" dot>
|
||||
{{ e.outcome === 'failure' ? 'fail' : e.outcome === 'success' ? 'ok' : '—' }}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
<Avatar v-else :name="e.actorEmail || '?'" :size="22" />
|
||||
<div>
|
||||
<div class="name">{{ e.actorEmail || 'system' }}</div>
|
||||
<Mono dim>{{ e.source }}</Mono>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono class="action">{{ e.action }}</Mono></td>
|
||||
<td><span class="target">{{ e.resourceName || e.resourceId || '—' }}</span></td>
|
||||
<td>
|
||||
<Mono v-if="e.tenantSlug">{{ e.tenantSlug }}</Mono>
|
||||
<Mono v-else dim>—</Mono>
|
||||
</td>
|
||||
<td><Mono dim>{{ shortIp(e.actorIp) }}</Mono></td>
|
||||
<td class="r">
|
||||
<Badge :tone="tone(e)" dot>
|
||||
{{ e.outcome === 'failure' ? 'fail' : e.outcome === 'success' ? 'ok' : '—' }}
|
||||
</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); }
|
||||
|
||||
Reference in New Issue
Block a user