02341d8ba5
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
364 lines
13 KiB
Vue
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>
|