114b419a69
Every page header's Refresh button rendered a downward chevron because the icon set had no refresh glyph. Added a circular-arrow 'refresh' icon to UiIcon and pointed all seven Refresh buttons (Overview, Tenants, Partners, Users, Operator team, Audit, Infrastructure) at it.
617 lines
23 KiB
Vue
617 lines
23 KiB
Vue
<script setup lang="ts">
|
||
import type { AuditEvent } from '~/types/audit'
|
||
|
||
// Local UI state for filters. Drives the params we pass to /api/audit.
|
||
const search = ref('')
|
||
const actionFilter = ref('') // exact or prefix (e.g. 'tenant')
|
||
const outcomeFilter = ref<'' | 'success' | 'failure'>('')
|
||
const tenantFilter = ref('')
|
||
|
||
const params = computed<Record<string, string>>(() => {
|
||
const p: Record<string, string> = {}
|
||
if (search.value.trim()) p.q = search.value.trim()
|
||
if (actionFilter.value) p.action = actionFilter.value
|
||
if (outcomeFilter.value) p.outcome = outcomeFilter.value
|
||
if (tenantFilter.value.trim()) p.tenantSlug = tenantFilter.value.trim()
|
||
return p
|
||
})
|
||
|
||
const { data: events, pending, refresh } = await useFetch<AuditEvent[]>('/api/audit', {
|
||
default: () => [],
|
||
query: params,
|
||
})
|
||
|
||
// Pagination via the `before` cursor — request the next page using the
|
||
// timestamp of the last event we have.
|
||
const olderPages = ref<AuditEvent[]>([])
|
||
const reachedEnd = ref(false)
|
||
|
||
const allEvents = computed(() => [...(events.value ?? []), ...olderPages.value])
|
||
|
||
// Reset older-page state whenever the live filter set changes; otherwise
|
||
// older results from a previous filter combination linger.
|
||
watch(params, () => {
|
||
olderPages.value = []
|
||
reachedEnd.value = false
|
||
})
|
||
|
||
async function loadMore() {
|
||
const last = allEvents.value[allEvents.value.length - 1]
|
||
if (!last) return
|
||
const next = await $fetch<AuditEvent[]>('/api/audit', {
|
||
query: { ...params.value, before: last.at },
|
||
})
|
||
if (!next.length) {
|
||
reachedEnd.value = true
|
||
return
|
||
}
|
||
olderPages.value.push(...next)
|
||
}
|
||
|
||
// Quick-pick filter chips that map to common queries.
|
||
const QUICK_ACTIONS = [
|
||
{ label: 'All actions', value: '' },
|
||
{ label: 'Tenants', value: 'tenant' },
|
||
{ label: 'Partners', value: 'partner' },
|
||
{ label: 'Flags', value: 'flag' },
|
||
{ label: 'Users', value: 'user' },
|
||
]
|
||
|
||
function fmtAbs(iso: string) {
|
||
const d = new Date(iso)
|
||
return d.toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'medium' })
|
||
}
|
||
function tone(e: AuditEvent): 'info' | 'warn' | 'bad' | 'ok' {
|
||
if (e.outcome === 'failure') return 'bad'
|
||
if (e.action.includes('suspended') || e.action.includes('killed') || e.action.includes('deleted') || e.action.includes('terminated')) return 'warn'
|
||
return 'info'
|
||
}
|
||
function shortIp(ip?: string) {
|
||
if (!ip) return '—'
|
||
// Strip v4-in-v6 prefix that node sockets sometimes report (::ffff:1.2.3.4 → 1.2.3.4)
|
||
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)
|
||
}
|
||
|
||
// Compact human-readable rendering of a single side of a diff. Objects collapse
|
||
// to JSON; null / undefined / empty string render as a dim em-dash so the
|
||
// "before" side of a freshly populated field reads as clearly empty.
|
||
function formatDiffValue(v: unknown): string {
|
||
if (v === null || v === undefined || v === '') return '—'
|
||
if (typeof v === 'object') return JSON.stringify(v)
|
||
return String(v)
|
||
}
|
||
|
||
// `metadata.diff` shape for partner.updated (and future *.updated actions):
|
||
// { fieldName: { from, to } }. Type-narrow safely; old audit rows that only
|
||
// carried `metadata.changes: string[]` are still rendered as plain chips by
|
||
// the fallback branch in the template.
|
||
interface DiffEntry { from: unknown; to: unknown }
|
||
function diffEntries(meta: Record<string, unknown> | undefined): [string, DiffEntry][] {
|
||
const d = meta?.diff
|
||
if (!d || typeof d !== 'object') return []
|
||
return Object.entries(d as Record<string, DiffEntry>)
|
||
}
|
||
|
||
// ── Tamper-evidence (Phase 3) + cold-storage archives (Phase 4) ────────
|
||
interface VerifyReport {
|
||
ok: boolean
|
||
totalEventsVerified: number
|
||
checkpointsChecked: number
|
||
latestCheckpointAt: string | null
|
||
latestVerifiedSeq: number | null
|
||
// Phase 4 additions — included in /audit/verify response.
|
||
oldestHotSeq: number | null
|
||
highestArchivedSeq: number | null
|
||
break?:
|
||
| { kind: 'event-hash-mismatch'; seq: number; expected: string; actual: string }
|
||
| { kind: 'event-prev-hash-mismatch'; seq: number; expected: string; actual: string }
|
||
| { kind: 'checkpoint-signature-mismatch'; headSeq: number }
|
||
}
|
||
interface CheckpointSummary {
|
||
at: string | null
|
||
headSeq: number | null
|
||
headHash: string | null
|
||
reason?: string
|
||
}
|
||
interface ArchiveBatch {
|
||
_id: string
|
||
archivedAt: string
|
||
startSeq: number
|
||
endSeq: number
|
||
eventCount: number
|
||
manifestSha256: string
|
||
jsonlKey: string
|
||
manifestKey: string
|
||
bytesUncompressed: number
|
||
}
|
||
|
||
const { data: latestCp, refresh: refreshCp } = useLazyFetch<CheckpointSummary>(
|
||
'/api/audit/checkpoint/latest',
|
||
{ default: () => ({ at: null, headSeq: null, headHash: null }), server: false },
|
||
)
|
||
const { data: archives, refresh: refreshArchives } = useLazyFetch<ArchiveBatch[]>(
|
||
'/api/audit/archives',
|
||
{ default: () => [], server: false },
|
||
)
|
||
const verifyReport = ref<VerifyReport | null>(null)
|
||
const verifying = ref(false)
|
||
const archiving = ref(false)
|
||
const archiveResult = ref<{ ok: boolean; reason?: string; eventCount?: number; startSeq?: number; endSeq?: number } | null>(null)
|
||
|
||
async function runVerify() {
|
||
verifying.value = true
|
||
try {
|
||
verifyReport.value = await $fetch<VerifyReport>('/api/audit/verify')
|
||
await refreshCp()
|
||
} finally {
|
||
verifying.value = false
|
||
}
|
||
}
|
||
|
||
async function forceCheckpoint() {
|
||
await $fetch('/api/audit/checkpoint', { method: 'POST' })
|
||
await refreshCp()
|
||
}
|
||
|
||
async function forceArchive() {
|
||
archiving.value = true
|
||
archiveResult.value = null
|
||
try {
|
||
archiveResult.value = await $fetch('/api/audit/archive/run', { method: 'POST' })
|
||
await Promise.all([refreshArchives(), refresh()])
|
||
} finally {
|
||
archiving.value = false
|
||
}
|
||
}
|
||
|
||
function fmtBytes(n: number): string {
|
||
if (n < 1024) return `${n} B`
|
||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||
}
|
||
|
||
function fmtRelative(iso: string | null | undefined): string {
|
||
if (!iso) return 'never'
|
||
const ms = Date.now() - new Date(iso).getTime()
|
||
if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`
|
||
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
|
||
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`
|
||
return new Date(iso).toLocaleDateString('da-DK')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<PageHeader
|
||
eyebrow="Compliance"
|
||
title="Global audit log"
|
||
:subtitle="`${allEvents.length} event${allEvents.length === 1 ? '' : 's'} · every privileged action recorded by platform-api`"
|
||
>
|
||
<template #actions>
|
||
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
|
||
<template #leading><UiIcon name="refresh" :size="13" /></template>
|
||
Refresh
|
||
</UiButton>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<div class="stage">
|
||
<div class="toolbar">
|
||
<div class="search">
|
||
<UiIcon name="search" :size="13" />
|
||
<input v-model="search" placeholder="action, actor, target, tenant…" type="text" />
|
||
</div>
|
||
<div class="chips">
|
||
<button
|
||
v-for="q in QUICK_ACTIONS"
|
||
:key="q.label"
|
||
:class="['chip', { on: actionFilter === q.value }]"
|
||
type="button"
|
||
@click="actionFilter = q.value"
|
||
>{{ q.label }}</button>
|
||
</div>
|
||
<div class="chips">
|
||
<button :class="['chip', { on: outcomeFilter === '' }]" type="button" @click="outcomeFilter = ''">Any</button>
|
||
<button :class="['chip', { on: outcomeFilter === 'success' }]" type="button" @click="outcomeFilter = 'success'">Success</button>
|
||
<button :class="['chip', { on: outcomeFilter === 'failure' }]" type="button" @click="outcomeFilter = 'failure'">Failure</button>
|
||
</div>
|
||
<Mono dim class="streaming">
|
||
<StatusDot color="var(--ok)" :size="6" :glow="false" />
|
||
live · backed by Mongo
|
||
</Mono>
|
||
</div>
|
||
|
||
<Card :pad="0">
|
||
<table v-if="allEvents.length">
|
||
<thead>
|
||
<tr>
|
||
<th class="caret-col"></th>
|
||
<th>Time</th>
|
||
<th>Actor</th>
|
||
<th>Action</th>
|
||
<th>Target</th>
|
||
<th>Tenant</th>
|
||
<th>IP</th>
|
||
<th class="r">Result</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<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">
|
||
<!-- New format: { diff: { field: {from, to} } } — render as a
|
||
table of before → after rows so the operator can read the
|
||
change inline. -->
|
||
<table v-if="diffEntries(e.metadata).length" class="diff-table">
|
||
<tbody>
|
||
<tr v-for="[field, entry] in diffEntries(e.metadata)" :key="field">
|
||
<td class="diff-key"><Mono>{{ field }}</Mono></td>
|
||
<td class="diff-from"><Mono dim>{{ formatDiffValue(entry.from) }}</Mono></td>
|
||
<td class="diff-arrow"><Mono dim>→</Mono></td>
|
||
<td class="diff-to"><Mono>{{ formatDiffValue(entry.to) }}</Mono></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<!-- Generic metadata renderer — covers older events that only
|
||
have metadata.changes (string[]) plus all non-update
|
||
actions whose metadata is free-form key/values. -->
|
||
<dl v-else 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>
|
||
</Card>
|
||
|
||
<div v-if="allEvents.length" class="footer">
|
||
<UiButton v-if="!reachedEnd" variant="secondary" :disabled="pending" @click="loadMore">
|
||
Load older events
|
||
</UiButton>
|
||
<Mono v-else dim>// reached the start of the log</Mono>
|
||
</div>
|
||
|
||
<!-- Tamper-evidence panel — Phase 3 -->
|
||
<Card :pad="0" class="verify-card">
|
||
<div class="verify-head">
|
||
<div>
|
||
<Eyebrow>Tamper evidence</Eyebrow>
|
||
<div class="cap">
|
||
Hash chain · {{ latestCp?.headSeq != null ? `signed through seq ${latestCp.headSeq}` : 'no checkpoints yet' }}
|
||
</div>
|
||
</div>
|
||
<div class="verify-actions">
|
||
<UiButton variant="secondary" :disabled="verifying" @click="forceCheckpoint">
|
||
Force checkpoint
|
||
</UiButton>
|
||
<UiButton variant="primary" :disabled="verifying" @click="runVerify">
|
||
{{ verifying ? 'Verifying…' : 'Verify chain' }}
|
||
</UiButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="verify-meta">
|
||
<div class="kv"><Eyebrow>Last signed checkpoint</Eyebrow><Mono>{{ fmtRelative(latestCp?.at ?? null) }}</Mono></div>
|
||
<div v-if="latestCp?.headHash" class="kv"><Eyebrow>Head hash</Eyebrow><Mono dim>{{ latestCp.headHash.slice(0, 16) }}…</Mono></div>
|
||
<div v-if="latestCp?.reason" class="kv"><Eyebrow>Reason</Eyebrow><Mono dim>{{ latestCp.reason }}</Mono></div>
|
||
</div>
|
||
|
||
<!-- Verify result -->
|
||
<div v-if="verifyReport" class="verify-result" :data-ok="verifyReport.ok">
|
||
<div v-if="verifyReport.ok" class="result-line">
|
||
<Badge tone="ok" dot>verified</Badge>
|
||
<Mono>{{ verifyReport.totalEventsVerified }} event(s) · {{ verifyReport.checkpointsChecked }} checkpoint(s) · last seq {{ verifyReport.latestVerifiedSeq ?? '—' }}</Mono>
|
||
</div>
|
||
<div v-else class="result-line">
|
||
<Badge tone="bad" dot>BROKEN</Badge>
|
||
<Mono v-if="verifyReport.break?.kind === 'event-hash-mismatch'">
|
||
event hash mismatch at seq {{ verifyReport.break.seq }} · stored {{ verifyReport.break.actual.slice(0, 16) }}… expected {{ verifyReport.break.expected.slice(0, 16) }}…
|
||
</Mono>
|
||
<Mono v-else-if="verifyReport.break?.kind === 'event-prev-hash-mismatch'">
|
||
chain link broken at seq {{ verifyReport.break.seq }} · prevHash mismatch
|
||
</Mono>
|
||
<Mono v-else-if="verifyReport.break?.kind === 'checkpoint-signature-mismatch'">
|
||
checkpoint signature mismatch at head seq {{ verifyReport.break.headSeq }}
|
||
</Mono>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<!-- Cold-storage archives — Phase 4 -->
|
||
<Card :pad="0" class="archive-card">
|
||
<div class="archive-head">
|
||
<div>
|
||
<Eyebrow>Cold storage</Eyebrow>
|
||
<div class="cap">
|
||
{{
|
||
archives?.length
|
||
? `archived through seq ${archives[0].endSeq} · ${archives.length} batch${archives.length === 1 ? '' : 'es'}`
|
||
: 'no archives yet · 90-day hot retention'
|
||
}}
|
||
</div>
|
||
</div>
|
||
<UiButton variant="secondary" :disabled="archiving" @click="forceArchive">
|
||
{{ archiving ? 'Archiving…' : 'Run archive now' }}
|
||
</UiButton>
|
||
</div>
|
||
|
||
<div v-if="archiveResult" class="archive-result" :data-ok="archiveResult.ok">
|
||
<Badge v-if="archiveResult.ok && archiveResult.eventCount" tone="ok" dot>archived</Badge>
|
||
<Badge v-else-if="archiveResult.ok" tone="info" dot>no-op</Badge>
|
||
<Badge v-else tone="bad" dot>failed</Badge>
|
||
<Mono v-if="archiveResult.ok && archiveResult.eventCount">
|
||
{{ archiveResult.eventCount }} event(s) · seq {{ archiveResult.startSeq }}–{{ archiveResult.endSeq }}
|
||
</Mono>
|
||
<Mono v-else dim>{{ archiveResult.reason || '—' }}</Mono>
|
||
</div>
|
||
|
||
<table v-if="archives?.length">
|
||
<thead>
|
||
<tr>
|
||
<th>Archived</th>
|
||
<th>Seq range</th>
|
||
<th>Events</th>
|
||
<th>Size</th>
|
||
<th>Manifest sha256</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="b in archives" :key="b._id">
|
||
<td><Mono>{{ fmtAbs(b.archivedAt) }}</Mono></td>
|
||
<td><Mono>{{ b.startSeq }}–{{ b.endSeq }}</Mono></td>
|
||
<td><Mono>{{ b.eventCount }}</Mono></td>
|
||
<td><Mono dim>{{ fmtBytes(b.bytesUncompressed) }}</Mono></td>
|
||
<td><Mono dim>{{ b.manifestSha256.slice(0, 16) }}…</Mono></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div v-else class="empty"><Mono dim>// no archive batches yet — events stay in hot Mongo for {{ '90' }} days, then move to S3 (MinIO in dev / Hetzner in prod)</Mono></div>
|
||
</Card>
|
||
|
||
<Mono dim class="note">
|
||
// hot tier: Mongo · cold tier: S3-compatible object storage ·
|
||
sha256 hash-chain with HMAC-signed checkpoints + signed archive
|
||
manifests · retention 90 days hot, indefinite cold · production
|
||
encryption at rest is SSE-S3
|
||
</Mono>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
|
||
|
||
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.search {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
color: var(--text-mute);
|
||
min-width: 320px;
|
||
}
|
||
.search input {
|
||
background: transparent;
|
||
border: 0;
|
||
outline: 0;
|
||
flex: 1;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
}
|
||
|
||
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.chip {
|
||
appearance: none;
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
color: var(--text-dim);
|
||
border-radius: 999px;
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
.chip:hover { color: var(--text); }
|
||
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||
|
||
.streaming { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
|
||
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th {
|
||
text-align: left;
|
||
font-family: var(--font-mono);
|
||
font-size: 9px;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--text-mute);
|
||
padding: 12px 16px;
|
||
font-weight: 500;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
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);
|
||
}
|
||
|
||
/* before → after diff table inside the expanded row */
|
||
.diff-table {
|
||
width: auto;
|
||
border-collapse: collapse;
|
||
margin: 0;
|
||
}
|
||
.diff-table td {
|
||
padding: 4px 14px 4px 0;
|
||
border: 0;
|
||
font-size: 12px;
|
||
vertical-align: top;
|
||
}
|
||
.diff-key { font-weight: 500; min-width: 110px; }
|
||
.diff-from { color: var(--text-mute); max-width: 360px; word-break: break-word; }
|
||
.diff-arrow { width: 18px; text-align: center; }
|
||
.diff-to { max-width: 360px; word-break: break-word; }
|
||
.name { font-size: 12px; font-weight: 500; }
|
||
.action { font-weight: 500; }
|
||
.target { font-size: 12px; color: var(--text-dim); }
|
||
.sys {
|
||
width: 22px; height: 22px;
|
||
border-radius: 4px;
|
||
background: var(--text);
|
||
color: var(--bg);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 9px;
|
||
font-family: var(--font-mono);
|
||
}
|
||
|
||
.empty { padding: 40px 20px; text-align: center; }
|
||
.footer { display: flex; justify-content: center; padding: 4px 0; }
|
||
.note { display: block; padding: 4px 4px 0 4px; }
|
||
|
||
/* Tamper-evidence panel */
|
||
.verify-card { margin-top: 8px; }
|
||
.verify-head {
|
||
padding: 14px 18px;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
gap: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.verify-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
|
||
.verify-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||
|
||
.verify-meta {
|
||
padding: 12px 18px;
|
||
display: flex; gap: 24px; flex-wrap: wrap;
|
||
}
|
||
.verify-meta .kv { display: flex; flex-direction: column; gap: 4px; }
|
||
|
||
.verify-result {
|
||
padding: 12px 18px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.verify-result[data-ok="true"] { background: rgba(31, 138, 91, 0.05); }
|
||
.verify-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
|
||
.result-line { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
|
||
/* Cold-storage archives panel */
|
||
.archive-card { margin-top: 8px; }
|
||
.archive-head {
|
||
padding: 14px 18px;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
gap: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.archive-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
|
||
.archive-result {
|
||
padding: 10px 18px;
|
||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.archive-result[data-ok="true"] { background: rgba(31, 138, 91, 0.05); }
|
||
.archive-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
|
||
.archive-card table { width: 100%; border-collapse: collapse; }
|
||
.archive-card th, .archive-card td { padding: 10px 18px; font-size: 12px; text-align: left; }
|
||
.archive-card th { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; border-bottom: 1px solid var(--border); }
|
||
.archive-card td { border-top: 1px solid var(--border); }
|
||
.archive-card .empty { padding: 16px 18px; }
|
||
</style>
|