feat(operator): visual-only screens with real-data overview (O.7)
- Overview (pages/index.vue): KPIs from real /tenants /partners /users, status meter, recent + needs-follow-up tables. Mock activity stream and incident banner overlay come from data/fixtures.ts. - Operator team: real GET /users filtered to platformAdmin === true, with last-seen + tenant counts. - Users (global): real read with All/Admins/Inactive views and search. - Infrastructure / Feature flags / Audit: mock fixtures only — wiring to real backends (Prometheus, OpenFeature, append-only audit) is tracked as follow-ups in OPERATOR-PLAN.md. - Placeholder pages (support/billing/reports/settings) via OpPlaceholder. - Shared: Stat, MetricCell, OpPlaceholder components, /api/users proxy, PlatformUser type. - .gitignore: scope the docker volumes data/ rule so apps/*/data/ is tracked again (operator carries mock fixtures there).
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { OP_AUDIT, type AuditEntry } from '~/data/fixtures'
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return OP_AUDIT
|
||||
return OP_AUDIT.filter((a) => {
|
||||
return (
|
||||
a.action.toLowerCase().includes(q) ||
|
||||
a.actor.toLowerCase().includes(q) ||
|
||||
a.target.toLowerCase().includes(q) ||
|
||||
a.tenant.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function tone(a: AuditEntry): 'info' | 'warn' | 'bad' {
|
||||
return a.tone
|
||||
}
|
||||
function label(a: AuditEntry) {
|
||||
return a.tone === 'bad' ? 'fail' : a.tone === 'warn' ? 'warn' : 'ok'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Compliance"
|
||||
title="Global audit log"
|
||||
subtitle="Every operator action, every system event — across all tenants, immutable."
|
||||
/>
|
||||
|
||||
<div class="stage">
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<UiIcon name="search" :size="13" />
|
||||
<input v-model="search" placeholder="action.type, actor, target…" type="text" />
|
||||
</div>
|
||||
<div class="streaming">
|
||||
<StatusDot color="var(--ok)" :size="6" />
|
||||
<Mono dim>streaming · mock</Mono>
|
||||
</div>
|
||||
<UiButton variant="secondary" disabled>
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Export CSV
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<table v-if="filtered.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="a in filtered" :key="a.id">
|
||||
<td><Mono>{{ a.when }}</Mono></td>
|
||||
<td class="actor">
|
||||
<div v-if="a.actor === 'system'" class="sys">sys</div>
|
||||
<Avatar v-else :name="a.actor" :size="22" />
|
||||
<div>
|
||||
<div class="name">{{ a.actor }}</div>
|
||||
<Mono dim>{{ a.role }}</Mono>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono class="action">{{ a.action }}</Mono></td>
|
||||
<td><span class="target">{{ a.target }}</span></td>
|
||||
<td>
|
||||
<Mono v-if="a.tenant !== '—'">{{ a.tenant }}</Mono>
|
||||
<Mono v-else dim>—</Mono>
|
||||
</td>
|
||||
<td><Mono dim>{{ a.ip }}</Mono></td>
|
||||
<td class="r"><Badge :tone="tone(a)" dot>{{ label(a) }}</Badge></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty"><Mono dim>// no matching entries</Mono></div>
|
||||
</Card>
|
||||
|
||||
<Mono dim class="note">// retention 7 years · write-once · mock fixtures — replace with real append-only audit collection</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);
|
||||
}
|
||||
.streaming { display: 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; }
|
||||
.note { display: block; padding: 4px 4px 0 4px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user