4d9e906ec1
Final piece of the audit work. Events older than the hot retention window
move to S3-compatible object storage with signed manifests. Production uses
Hetzner Object Storage; dev uses a MinIO container with the same API.
Infra (infrastructure/docker-compose):
- New `minio` service exposing the S3 API at minio:9000 + admin console at
minio.dezky.local. Healthchecked. Bucket-init sidecar runs `mc mb` once
to create `dezky-audit`; safe to re-run.
- .env adds MINIO_ROOT_USER + MINIO_ROOT_PASSWORD.
- platform-api env: AUDIT_COLD_{ENDPOINT,REGION,BUCKET,ACCESS_KEY,SECRET_KEY}
+ AUDIT_HOT_RETENTION_DAYS=90 + ARCHIVE_ENABLED=false (dormant in dev;
operator UI's "Run archive now" bypasses this gate). AUDIT_COLD_SSE
opts into SSE-S3 — left unset in dev because MinIO without a KMS rejects
AES256 PUTs with "KMS is not configured".
Platform-api (services/platform-api/src/cold/):
- cold-storage.client.ts: thin @aws-sdk/client-s3 wrapper — put/head/list.
forcePathStyle=true so MinIO and Hetzner both work; same code, env-swap.
- archive.service.ts: runOnce() selects chained events with at < cutoff →
serializes to JSONL → gzip → sha256s → uploads JSONL + signed manifest
→ HEAD-confirms both objects exist → records an ArchiveBatch doc → only
then deletes from hot Mongo. Crash-safe: a failed upload leaves events
in hot. Manifest uses the Phase 3 AUDIT_SIGNING_KEY (HMAC-SHA-256), so
archives + checkpoints share trust chain. Bypassable via { override:
true } for the operator's UI force-run.
- archive.worker.ts: hourly tick guarded by configured run-hour-UTC
(default 03:00) + day-guard so the same UTC day doesn't archive twice.
Disabled until ARCHIVE_ENABLED=true.
- archive-batch.schema.ts: { archivedAt, startSeq, endSeq, eventCount,
manifestSha256, jsonlKey, manifestKey, bytesUncompressed }. The
manifest sha256 stored in Mongo lets us detect manifest tampering
without downloading the actual manifest.
Audit module additions:
- audit.controller.ts: GET /audit/archives, POST /audit/archive/run,
/audit/verify now reports { oldestHotSeq, highestArchivedSeq } so the
UI shows the tier boundary.
Operator UI (apps/operator):
- 2 new proxies: /api/audit/archives + /api/audit/archive/run (force
override=true). Both behind operator auth via the existing platformApi
helper.
- audit.vue: new "Cold storage" card with batch table (archived-at, seq
range, event count, size, truncated manifest sha256), "Run archive
now" button + per-run result line.
Smoke-tested end-to-end:
- 7 chained events in hot. /api/audit/archive/run → ok=true, batchId
returned. JSONL + manifest both exist in MinIO (verified via mc ls +
mc cat). Mongo's chained set went 7 → 0. Verify reports
highestArchivedSeq=1446 (since we burn-allocate seqs on Authentik
dup-key rejections). Operator /audit panel shows the batch with
manifest hash 1d8263…
- First attempt with SSE-S3 enabled failed cleanly (MinIO KMS not
configured) — archive service correctly left events in hot Mongo.
Made SSE opt-in via AUDIT_COLD_SSE=true; prod turns it on.
Out of scope (each could be its own session):
- Restore-to-hot endpoint (today: download from S3 + offline query)
- Client-side encryption (today: SSE-S3 in prod, none in dev)
- Multi-region replication
- Soft TTL safety net (defense-in-depth on top of app-managed deletion)
This completes the four-phase audit log work:
1. platform-api as audit hub
2. External system ingest (Authentik / Stalwart / OCIS)
3. Hash-chain + signed checkpoints (tamper evidence)
4. Cold-storage archival (retention without unbounded Mongo growth)
483 lines
17 KiB
Vue
483 lines
17 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:/, '')
|
||
}
|
||
|
||
// ── 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="chevDown" :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>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>
|
||
<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>
|
||
</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; }
|
||
.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>
|