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:
+313
-104
@@ -1,137 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
// O.4 deliverable: real shell wrapping the placeholder dashboard. The smoke
|
||||
// test from O.3 stays so we can keep verifying the audience chain after
|
||||
// every restart. Real Overview content lands in O.7.
|
||||
import type { Tenant } from '~/types/tenant'
|
||||
import type { Partner } from '~/types/partner'
|
||||
import type { PlatformUser } from '~/types/user'
|
||||
import { SERVICES, INCIDENT, OP_AUDIT } from '~/data/fixtures'
|
||||
|
||||
const { user } = useOidcAuth()
|
||||
const smokeResult = ref<string | null>(null)
|
||||
const smokeBusy = ref(false)
|
||||
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: () => [] })
|
||||
|
||||
async function createTestPartner() {
|
||||
smokeBusy.value = true
|
||||
smokeResult.value = null
|
||||
try {
|
||||
const res = await $fetch('/api/operator-smoke-test', { method: 'POST' })
|
||||
smokeResult.value = `200 ${JSON.stringify(res).slice(0, 200)}`
|
||||
} catch (err: unknown) {
|
||||
const e = err as { data?: { message?: string; data?: { message?: string } }; statusCode?: number }
|
||||
const code = e.statusCode ?? '?'
|
||||
const msg = e.data?.data?.message ?? e.data?.message ?? String(err)
|
||||
smokeResult.value = `${code} ${msg}`
|
||||
} finally {
|
||||
smokeBusy.value = false
|
||||
}
|
||||
const pending = computed(() => tp.value || pp.value || up.value)
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([rT(), rP(), rU()])
|
||||
}
|
||||
|
||||
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="Overview"
|
||||
:title="`Hi, ${user?.userInfo?.name || user?.userName || 'operator'}.`"
|
||||
subtitle="O.4 scaffolding · sidebar + topbar + design tokens wired up. Real dashboard tiles, metrics and incident panel land in O.7."
|
||||
eyebrow="Operator · operator.dezky.local"
|
||||
title="Platform overview"
|
||||
:subtitle="`${stats.tenants} tenants · ${stats.partners} partners · ${stats.users} platform users`"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Docs
|
||||
</UiButton>
|
||||
<UiButton variant="primary">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
New tenant
|
||||
<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">
|
||||
<Card>
|
||||
<div class="row">
|
||||
<div>
|
||||
<h2>Smoke test · POST /partners</h2>
|
||||
<p>
|
||||
Forwards your access token to platform-api. Operator-scoped tokens succeed
|
||||
(200 first time, 409 thereafter). Customer-portal tokens return 403.
|
||||
</p>
|
||||
</div>
|
||||
<UiButton variant="primary" :disabled="smokeBusy" @click="createTestPartner">
|
||||
{{ smokeBusy ? 'Calling…' : 'Create partner' }}
|
||||
</UiButton>
|
||||
<button v-if="incidentActive" class="incident" type="button">
|
||||
<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>
|
||||
<pre v-if="smokeResult" class="result">{{ smokeResult }}</pre>
|
||||
</Card>
|
||||
<Mono>IC: {{ INCIDENT.ic }}</Mono>
|
||||
<UiIcon name="chevRight" :size="14" />
|
||||
</button>
|
||||
|
||||
<Card>
|
||||
<h2 class="cap">Session</h2>
|
||||
<div class="meta">
|
||||
<div class="kv"><Eyebrow>subject</Eyebrow><Mono>{{ user?.userName }}</Mono></div>
|
||||
<div class="kv"><Eyebrow>email</Eyebrow><Mono>{{ user?.userInfo?.email }}</Mono></div>
|
||||
<div class="kv">
|
||||
<Eyebrow>groups</Eyebrow>
|
||||
<span class="groups">
|
||||
<Badge
|
||||
v-for="g in (user?.userInfo as { groups?: string[] } | undefined)?.groups || []"
|
||||
:key="g"
|
||||
:tone="g === 'dezky-platform-admins' ? 'accent' : 'neutral'"
|
||||
>{{ g }}</Badge>
|
||||
</span>
|
||||
<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>streaming · mock</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv"><Eyebrow>token aud</Eyebrow><Badge tone="invert">dezky-operator</Badge></div>
|
||||
<div>
|
||||
<div v-for="a in OP_AUDIT.slice(0, 8)" :key="a.id" class="row">
|
||||
<Mono dim>{{ a.when }}</Mono>
|
||||
<div class="entry">
|
||||
<div class="line">
|
||||
<span class="actor">{{ a.actor }}</span>
|
||||
<Mono dim>{{ a.action }}</Mono>
|
||||
<span class="arrow">→</span>
|
||||
<span class="target">{{ a.target }}</span>
|
||||
</div>
|
||||
<div v-if="a.tenant !== '—'" class="tenant"><Mono dim>tenant: {{ a.tenant }}</Mono></div>
|
||||
</div>
|
||||
<Badge :tone="a.tone === 'bad' ? 'bad' : a.tone === 'warn' ? 'warn' : 'info'" dot>{{ a.tone }}</Badge>
|
||||
</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>
|
||||
</Card>
|
||||
</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;
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 1100px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.row { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
||||
|
||||
h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-mute);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin: 16px 0 0 0;
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
.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-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
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; }
|
||||
|
||||
.cap {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 14px 0;
|
||||
.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); }
|
||||
|
||||
.meta { display: flex; flex-direction: column; gap: 12px; }
|
||||
.kv { display: flex; align-items: center; gap: 16px; }
|
||||
.kv :first-child { width: 110px; flex-shrink: 0; }
|
||||
.groups { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.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;
|
||||
}
|
||||
.row:last-child { border-bottom: none; }
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user