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:/, '')
|
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) ────────
|
// ── Tamper-evidence (Phase 3) + cold-storage archives (Phase 4) ────────
|
||||||
interface VerifyReport {
|
interface VerifyReport {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -205,6 +225,7 @@ function fmtRelative(iso: string | null | undefined): string {
|
|||||||
<table v-if="allEvents.length">
|
<table v-if="allEvents.length">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="caret-col"></th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th>Actor</th>
|
<th>Actor</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
@@ -215,29 +236,59 @@ function fmtRelative(iso: string | null | undefined): string {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="e in allEvents" :key="e._id">
|
<template v-for="e in allEvents" :key="e._id">
|
||||||
<td><Mono>{{ fmtAbs(e.at) }}</Mono></td>
|
<tr
|
||||||
<td class="actor">
|
:class="['row', { expandable: hasDetails(e), open: expanded.has(e._id) }]"
|
||||||
<div v-if="e.actorType === 'system'" class="sys">sys</div>
|
@click="hasDetails(e) && toggleRow(e._id)"
|
||||||
<Avatar v-else :name="e.actorEmail || '?'" :size="22" />
|
>
|
||||||
<div>
|
<td class="caret-col">
|
||||||
<div class="name">{{ e.actorEmail || 'system' }}</div>
|
<UiIcon
|
||||||
<Mono dim>{{ e.source }}</Mono>
|
v-if="hasDetails(e)"
|
||||||
</div>
|
:name="expanded.has(e._id) ? 'chevDown' : 'chevRight'"
|
||||||
</td>
|
:size="12"
|
||||||
<td><Mono class="action">{{ e.action }}</Mono></td>
|
/>
|
||||||
<td><span class="target">{{ e.resourceName || e.resourceId || '—' }}</span></td>
|
</td>
|
||||||
<td>
|
<td><Mono>{{ fmtAbs(e.at) }}</Mono></td>
|
||||||
<Mono v-if="e.tenantSlug">{{ e.tenantSlug }}</Mono>
|
<td class="actor">
|
||||||
<Mono v-else dim>—</Mono>
|
<div v-if="e.actorType === 'system'" class="sys">sys</div>
|
||||||
</td>
|
<Avatar v-else :name="e.actorEmail || '?'" :size="22" />
|
||||||
<td><Mono dim>{{ shortIp(e.actorIp) }}</Mono></td>
|
<div>
|
||||||
<td class="r">
|
<div class="name">{{ e.actorEmail || 'system' }}</div>
|
||||||
<Badge :tone="tone(e)" dot>
|
<Mono dim>{{ e.source }}</Mono>
|
||||||
{{ e.outcome === 'failure' ? 'fail' : e.outcome === 'success' ? 'ok' : '—' }}
|
</div>
|
||||||
</Badge>
|
</td>
|
||||||
</td>
|
<td><Mono class="action">{{ e.action }}</Mono></td>
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div v-else class="empty"><Mono dim>// no events match the current filters</Mono></div>
|
<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; }
|
th.r, td.r { text-align: right; }
|
||||||
td { padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
|
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; }
|
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; }
|
.name { font-size: 12px; font-weight: 500; }
|
||||||
.action { font-weight: 500; }
|
.action { font-weight: 500; }
|
||||||
.target { font-size: 12px; color: var(--text-dim); }
|
.target { font-size: 12px; color: var(--text-dim); }
|
||||||
|
|||||||
Reference in New Issue
Block a user