Files
dezky/apps/operator/pages/index.vue
T
Ronni Baslund 02341d8ba5 feat(audit): platform-api audit log + operator UI wired to real events
Phase 1 of the audit work — capture everything we control today, ingest from
external systems (Authentik / OCIS / Stalwart) in a later phase. The mock
OP_AUDIT fixture is gone; both the /audit page and Overview's activity card
now show real events recorded by AuditService.record() in platform-api.

Schema (services/platform-api/src/schemas/audit-event.schema.ts):
  AuditEvent { at, actorType, actorId, actorEmail, actorIp, action, outcome,
    resourceType, resourceId, resourceName, tenantSlug, partnerSlug, source,
    metadata, prevHash, hash }
  Indexes: {at:-1}, {tenantSlug,at:-1}, {actorId,at:-1}, {action,at:-1}.
  prevHash/hash are nullable now; hash-chain tamper evidence is a later phase.

AuditService:
  - record() — best-effort write, swallows errors so the underlying mutation
    that succeeded isn't failed by a downstream log issue. Surfaces failures
    via Logger.
  - list() — filters: since/until/before, action (exact OR prefix match
    via leading-anchor regex), tenantSlug, partnerSlug, actorEmail, outcome,
    free-text q across action/resourceName/actorEmail/tenantSlug, limit
    (default 100, max 500). Cursor pagination via `before`.
  - No UPDATE/DELETE surface — entries are append-only by construction.

AuditController: GET /audit, behind JwtAuthGuard + OperatorGuard. No mutations
exposed; entries written internally by other modules.

X-Forwarded-For threading:
  - apps/operator/server/utils/platform-api.ts forwards the originating
    client IP to platform-api so audit entries carry a real address.
  - services/platform-api/src/auth/client-ip.ts extracts leftmost
    X-Forwarded-For, falls back to socket.remoteAddress.

Instrumented mutations (every one threads actor + IP through):
  Tenants: create, update, softDelete, setStatus(suspend/resume)
  Partners: create, update, terminate
  Flags:   create, update (incl. flag.killed verb when state=off+note=kill-switch),
           remove
  Users:   deactivate

Each controller resolves the User doc via ActorService, extracts IP via
clientIp(req), and passes { userId, email, ip } as AuditActor to the service.
FlagsService's local ActorRef collapses to AuditActor so flag history and the
audit log share one shape.

Operator UI:
  - /api/audit proxy that forwards query params verbatim
  - types/audit.ts
  - pages/audit.vue: real list with quick-pick action chips (All/Tenants/
    Partners/Flags/Users), outcome filter, free-text search, "Load older
    events" cursor pagination
  - pages/index.vue: Overview activity card swaps mock OP_AUDIT for the
    same /api/audit endpoint, rows link into /audit
  - data/fixtures.ts: OP_AUDIT / AuditEntry / AuditTone exports removed

Verified end-to-end: suspended + resumed acme, flipped oci_versioning through
rollout → kill → on, then /audit returned all 5 events with the right action
verbs (tenant.suspended, tenant.resumed, flag.updated, flag.killed,
flag.updated), actor admin@dezky.local, IP 192.168.65.1. Filters (action
prefix + free-text q) narrow correctly.

Out of scope for this commit (each gets its own conversation):
  - Authentik / OCIS / Stalwart ingest adapters (Phase 2)
  - Hash-chain tamper evidence (Phase 3)
  - TTL + cold-storage archival to Hetzner Object Storage (Phase 4)
  - GDPR right-to-erasure tooling
2026-05-24 19:50:24 +02:00

364 lines
13 KiB
Vue

<script setup lang="ts">
import type { Tenant } from '~/types/tenant'
import type { Partner } from '~/types/partner'
import type { PlatformUser } from '~/types/user'
import type { AuditEvent } from '~/types/audit'
import { SERVICES, INCIDENT } from '~/data/fixtures'
const { data: tenants, pending: tp, refresh: rT } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
const { data: users, pending: up, refresh: rU } = await useFetch<PlatformUser[]>('/api/users', { default: () => [] })
const { data: auditEvents, refresh: rA } = await useFetch<AuditEvent[]>('/api/audit', {
default: () => [],
query: { limit: 8 },
})
function fmtClock(iso: string) {
return new Date(iso).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
}
const { open: openIncident } = useIncidentModal()
const pending = computed(() => tp.value || pp.value || up.value)
async function refresh() {
await Promise.all([rT(), rP(), rU(), rA()])
}
const degradedCount = computed(() => SERVICES.filter((s) => s.status !== 'ok').length)
const incidentActive = computed(() => degradedCount.value > 0)
const stats = computed(() => ({
tenants: tenants.value?.length ?? 0,
partners: partners.value?.length ?? 0,
users: users.value?.length ?? 0,
active: (tenants.value ?? []).filter((t) => t.status === 'active').length,
pendingT: (tenants.value ?? []).filter((t) => t.status === 'pending').length,
suspended: (tenants.value ?? []).filter((t) => t.status === 'suspended').length,
}))
const newTenants = computed(() => {
return [...(tenants.value ?? [])]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 4)
})
const flaggedTenants = computed(() => {
return (tenants.value ?? []).filter((t) => t.status === 'suspended' || t.status === 'pending').slice(0, 4)
})
function fmtDate(d: string) {
return new Date(d).toLocaleDateString('da-DK', { day: '2-digit', month: 'short' })
}
</script>
<template>
<div>
<PageHeader
eyebrow="Operator · operator.dezky.local"
title="Platform overview"
:subtitle="`${stats.tenants} tenants · ${stats.partners} partners · ${stats.users} platform users`"
>
<template #actions>
<UiButton variant="secondary" :disabled="pending" @click="refresh">
<template #leading><UiIcon name="chevDown" :size="13" /></template>
Refresh
</UiButton>
<NuxtLink to="/tenants" class="primary-link">
<UiButton variant="primary">
<template #leading><UiIcon name="plus" :size="13" /></template>
New tenant
</UiButton>
</NuxtLink>
</template>
</PageHeader>
<div class="stage">
<button v-if="incidentActive" class="incident" type="button" @click="openIncident">
<span class="pill">
<span class="dot" />
{{ INCIDENT.severity }} · ACTIVE
</span>
<div class="incident-body">
<div class="incident-title">{{ INCIDENT.title }}</div>
<div class="incident-sub">Started {{ INCIDENT.started }} · {{ INCIDENT.duration }} duration · {{ INCIDENT.affected }}</div>
</div>
<Mono>IC: {{ INCIDENT.ic }}</Mono>
<UiIcon name="chevRight" :size="14" />
</button>
<div class="vitals">
<NuxtLink to="/tenants" class="vital">
<Stat label="Tenants" :value="stats.tenants" :hint="`${stats.active} active`" />
</NuxtLink>
<NuxtLink to="/partners" class="vital">
<Stat label="Partners" :value="stats.partners" />
</NuxtLink>
<NuxtLink to="/users" class="vital">
<Stat label="Platform users" :value="stats.users" />
</NuxtLink>
<NuxtLink to="/infrastructure" class="vital">
<Stat
label="Services"
:value="incidentActive ? `${degradedCount} degraded` : 'all green'"
:delta-tone="incidentActive ? 'down' : 'up'"
:hint="incidentActive ? 'P2 · authentik' : `${SERVICES.length} / ${SERVICES.length} healthy`"
/>
</NuxtLink>
</div>
<div class="grid">
<Card :pad="0">
<div class="head">
<div>
<Eyebrow>Live · platform-wide</Eyebrow>
<div class="cap">Activity</div>
</div>
<div class="streaming">
<StatusDot color="var(--ok)" :size="6" />
<Mono dim>live · {{ auditEvents.length }} recent</Mono>
</div>
</div>
<div>
<NuxtLink v-for="a in auditEvents" :key="a._id" class="row" to="/audit">
<Mono dim>{{ fmtClock(a.at) }}</Mono>
<div class="entry">
<div class="line">
<span class="actor">{{ a.actorEmail || 'system' }}</span>
<Mono dim>{{ a.action }}</Mono>
<span class="arrow"></span>
<span class="target">{{ a.resourceName || a.resourceId || '—' }}</span>
</div>
<div v-if="a.tenantSlug" class="tenant"><Mono dim>tenant: {{ a.tenantSlug }}</Mono></div>
</div>
<Badge :tone="a.outcome === 'failure' ? 'bad' : 'info'" dot>{{ a.outcome === 'failure' ? 'fail' : 'ok' }}</Badge></NuxtLink>
<div v-if="!auditEvents.length" class="row empty-row">
<Mono dim>// no audit events yet — perform an action in operator and reload</Mono>
</div>
</div>
</Card>
<div class="side">
<Card :pad="0">
<div class="head">
<div>
<Eyebrow>Status · platform-wide</Eyebrow>
<div class="cap">{{ stats.active }} / {{ stats.tenants }} active</div>
</div>
</div>
<div class="status-rows">
<div class="status-row">
<Mono>active</Mono>
<div class="meter"><div class="meter-fill ok" :style="{ width: stats.tenants ? `${(stats.active / stats.tenants) * 100}%` : '0%' }" /></div>
<Mono>{{ stats.active }}</Mono>
</div>
<div class="status-row">
<Mono>pending</Mono>
<div class="meter"><div class="meter-fill warn" :style="{ width: stats.tenants ? `${(stats.pendingT / stats.tenants) * 100}%` : '0%' }" /></div>
<Mono>{{ stats.pendingT }}</Mono>
</div>
<div class="status-row">
<Mono>suspended</Mono>
<div class="meter"><div class="meter-fill bad" :style="{ width: stats.tenants ? `${(stats.suspended / stats.tenants) * 100}%` : '0%' }" /></div>
<Mono>{{ stats.suspended }}</Mono>
</div>
</div>
</Card>
<Card :pad="0">
<div class="head no-border">
<div>
<Eyebrow>Reseller channel</Eyebrow>
<div class="cap">Partner mix</div>
</div>
</div>
<div v-if="partners?.length" class="partner-list">
<NuxtLink v-for="p in partners.slice(0, 4)" :key="p._id" :to="`/partners/${p.slug}`" class="partner-row">
<div class="partner-name">{{ p.name }}</div>
<Mono dim>{{ p.customers }} customers · {{ p.marginPct }}% margin</Mono>
</NuxtLink>
</div>
<div v-else class="partner-empty">
<Mono dim>// no partners yet — invite one from /partners</Mono>
</div>
</Card>
</div>
</div>
<div class="grid2">
<Card :pad="0">
<div class="head">
<div>
<Eyebrow>Recently provisioned</Eyebrow>
<div class="cap">New tenants</div>
</div>
</div>
<table v-if="newTenants.length">
<thead><tr><th>Tenant</th><th>Plan</th><th>Created</th></tr></thead>
<tbody>
<tr v-for="t in newTenants" :key="t._id" @click="$router.push(`/tenants/${t.slug}`)">
<td class="name">{{ t.name }}</td>
<td><Mono>{{ t.plan }}</Mono></td>
<td><Mono dim>{{ fmtDate(t.createdAt) }}</Mono></td>
</tr>
</tbody>
</table>
<div v-else class="empty"><Mono dim>// no tenants yet</Mono></div>
</Card>
<Card :pad="0">
<div class="head">
<div>
<Eyebrow>Needs follow-up</Eyebrow>
<div class="cap">Pending & suspended</div>
</div>
</div>
<table v-if="flaggedTenants.length">
<thead><tr><th>Tenant</th><th>Status</th><th>Plan</th></tr></thead>
<tbody>
<tr v-for="t in flaggedTenants" :key="t._id" @click="$router.push(`/tenants/${t.slug}`)">
<td class="name">{{ t.name }}</td>
<td><Badge :tone="t.status === 'suspended' ? 'bad' : 'warn'" dot>{{ t.status }}</Badge></td>
<td><Mono>{{ t.plan }}</Mono></td>
</tr>
</tbody>
</table>
<div v-else class="empty"><Mono dim>// everything looks healthy</Mono></div>
</Card>
</div>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
.primary-link { text-decoration: none; }
.incident {
width: 100%;
text-align: left;
background: rgba(226, 48, 48, 0.06);
border: 1px solid rgba(226, 48, 48, 0.3);
border-left: 3px solid var(--bad);
border-radius: 8px;
padding: 14px 18px;
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
color: var(--text);
font-family: inherit;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: var(--bad);
color: #fff;
border-radius: 4px;
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
letter-spacing: 0.08em;
}
.pill .dot { width: 6px; height: 6px; border-radius: 999px; background: #fff; }
.incident-body { flex: 1; min-width: 0; }
.incident-title { font-size: 14px; font-weight: 600; }
.incident-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.vitals {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.vital {
background: var(--surface);
padding: 20px;
text-decoration: none;
color: inherit;
transition: background 0.1s;
}
.vital:hover { background: var(--bg); }
.grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.head {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.head.no-border { border-bottom: none; }
.cap { font-family: var(--font-display); font-weight: 600; font-size: 17px; margin-top: 4px; }
.streaming { display: flex; align-items: center; gap: 8px; }
.row {
display: grid;
grid-template-columns: 60px 1fr 80px;
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
font-size: 12px;
text-decoration: none;
color: inherit;
}
.row:hover { background: var(--surface); }
.row:last-child { border-bottom: none; }
.row.empty-row { grid-template-columns: 1fr; }
.entry { min-width: 0; }
.line { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.actor { font-weight: 500; }
.arrow { color: var(--text-dim); }
.target { color: var(--text-dim); }
.tenant { margin-top: 2px; }
.side { display: flex; flex-direction: column; gap: 16px; }
.status-rows { padding: 16px 20px; display: flex; flex-direction: column; gap: 12px; }
.status-row { display: grid; grid-template-columns: 80px 1fr 40px; align-items: center; gap: 12px; }
.meter { height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; }
.meter-fill { height: 100%; }
.meter-fill.ok { background: var(--ok); }
.meter-fill.warn { background: var(--warn); }
.meter-fill.bad { background: var(--bad); }
.partner-list { padding: 4px 0 12px 0; }
.partner-row {
display: block;
padding: 10px 20px;
text-decoration: none;
color: inherit;
border-top: 1px solid var(--border);
}
.partner-row:hover { background: var(--bg); }
.partner-name { font-size: 12px; font-weight: 500; }
.partner-empty { padding: 16px 20px; }
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 20px;
font-weight: 500;
border-bottom: 1px solid var(--border);
}
td { padding: 10px 20px; font-size: 12px; border-top: 1px solid var(--border); }
td.name { font-weight: 500; }
tbody tr { cursor: pointer; }
tbody tr:hover { background: var(--bg); }
.empty { padding: 32px 20px; text-align: center; }
</style>