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:
Ronni Baslund
2026-05-24 08:17:26 +02:00
parent fbbb43e3e2
commit e0ac643e80
18 changed files with 1332 additions and 120 deletions
+152
View File
@@ -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>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts"></script>
<template>
<OpPlaceholder
eyebrow="Commercial"
title="Platform billing"
icon="card"
body="MRR, invoice runs, dunning state, Stripe sync. Will populate once Subscription gains real pricing and the Stripe integration ships."
/>
</template>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { FLAGS, type FeatureFlag } from '~/data/fixtures'
function stateTone(f: FeatureFlag): 'ok' | 'neutral' | 'warn' | 'info' {
switch (f.state) {
case 'on': return 'ok'
case 'off': return 'neutral'
case 'rollout': return 'warn'
case 'targeted': return 'info'
}
}
function stateLabel(f: FeatureFlag) {
if (f.state === 'rollout') return `${f.pct}% rollout`
return f.state
}
</script>
<template>
<div>
<PageHeader
eyebrow="Engineering"
title="Feature flags"
subtitle="Toggle, target, and roll out platform features. Every change is logged."
>
<template #actions>
<UiButton variant="primary" disabled>
<template #leading><UiIcon name="plus" :size="13" /></template>
New flag
</UiButton>
</template>
</PageHeader>
<div class="stage">
<Card :pad="0">
<table>
<thead>
<tr>
<th>Key</th>
<th>State</th>
<th>Rollout</th>
<th>Scope</th>
<th>Last modified</th>
</tr>
</thead>
<tbody>
<tr v-for="f in FLAGS" :key="f.key">
<td>
<div class="key">
<span class="key-tile" :data-state="f.state">
<UiIcon name="plug" :size="11" />
</span>
<Mono class="key-name">{{ f.key }}</Mono>
</div>
</td>
<td><Badge :tone="stateTone(f)" dot>{{ stateLabel(f) }}</Badge></td>
<td>
<div v-if="f.state === 'rollout'" class="rollout">
<div class="bar"><div class="fill" :style="{ width: `${f.pct}%` }" /></div>
<Mono>{{ f.pct }}%</Mono>
</div>
<Mono v-else dim></Mono>
</td>
<td><Mono dim>{{ f.scope }}</Mono></td>
<td><Mono dim>{{ f.modified }}</Mono></td>
</tr>
</tbody>
</table>
</Card>
<Mono dim class="note">// mock fixtures — wire to a feature-flag service (Unleash / OpenFeature) in a follow-up</Mono>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
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: 12px 20px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
.key { display: flex; align-items: center; gap: 10px; }
.key-tile {
width: 22px; height: 22px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
}
.key-tile[data-state='on'] { background: rgba(31, 138, 91, 0.12); color: var(--ok); }
.key-tile[data-state='off'] { background: rgba(128, 128, 128, 0.12); color: var(--text-mute); }
.key-tile[data-state='rollout'] { background: rgba(232, 154, 31, 0.12); color: var(--warn); }
.key-tile[data-state='targeted']{ background: rgba(42, 111, 219, 0.1); color: var(--info); }
.key-name { font-weight: 600; }
.rollout { display: inline-flex; align-items: center; gap: 10px; width: 160px; }
.bar { flex: 1; height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; }
.fill { height: 100%; background: var(--warn); }
.note { display: block; padding: 4px 4px 0 4px; }
</style>
+313 -104
View File
@@ -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>
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { SERVICES, INCIDENT, type PlatformService } from '~/data/fixtures'
const degradedCount = computed(() => SERVICES.filter((s) => s.status !== 'ok').length)
const incidentActive = computed(() => degradedCount.value > 0)
function tone(s: PlatformService): 'ok' | 'warn' | 'bad' {
return s.status
}
function label(s: PlatformService) {
return s.status === 'ok' ? 'operational' : s.status === 'warn' ? 'degraded' : 'down'
}
</script>
<template>
<div>
<PageHeader
eyebrow="Operations"
title="Infrastructure"
subtitle="Health of every service that makes up the Dezky platform."
>
<template #actions>
<UiButton variant="secondary" disabled>
<template #leading><UiIcon name="chevDown" :size="13" /></template>
Refresh
</UiButton>
<UiButton variant="secondary" disabled>
<template #leading><UiIcon name="calendar" :size="13" /></template>
Schedule maintenance
</UiButton>
</template>
</PageHeader>
<div class="stage">
<div v-if="incidentActive" class="incident">
<span class="pill">
<span class="dot" />
{{ INCIDENT.severity }} · ACTIVE
</span>
<div class="body">
<div class="title">{{ INCIDENT.title }}</div>
<div class="sub">Started {{ INCIDENT.started }} · IC: {{ INCIDENT.ic }}</div>
</div>
<UiButton variant="primary" disabled>Open incident</UiButton>
</div>
<div class="grid">
<Card v-for="s in SERVICES" :key="s.id" :pad="0">
<div class="head">
<div>
<div class="name">{{ s.name }}</div>
<Mono dim>{{ s.role }}</Mono>
</div>
<Badge :tone="tone(s)" dot>{{ label(s) }}</Badge>
</div>
<div class="metrics">
<MetricCell label="uptime · 30d" :value="`${s.uptime.toFixed(2)}%`" />
<MetricCell label="p95 latency" :value="`${s.p95}ms`" :tone="s.p95 > 300 ? 'warn' : undefined" />
<MetricCell label="error rate" :value="`${s.err.toFixed(3)}%`" :tone="s.err > 0.04 ? 'warn' : undefined" />
</div>
<div class="foot">
<Mono dim>last incident · {{ s.last }}</Mono>
<Mono dim>details </Mono>
</div>
</Card>
</div>
<Mono dim class="note">// mock fixtures — wire up to Docker healthchecks + Prometheus in a follow-up</Mono>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
.incident {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
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;
}
.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; }
.body { flex: 1; min-width: 0; }
.title { font-size: 14px; font-weight: 600; }
.sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.head {
padding: 16px 18px 12px 18px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.name { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
.metrics {
padding: 12px 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.foot {
padding: 10px 18px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
}
.note { display: block; padding: 4px 4px 0 4px; }
</style>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { PlatformUser } from '~/types/user'
const { data: users, pending, refresh } = await useFetch<PlatformUser[]>('/api/users', {
default: () => [],
})
const operators = computed(() => (users.value ?? []).filter((u) => u.platformAdmin))
function lastSeen(u: PlatformUser) {
if (!u.lastLoginAt) return '—'
const diff = Date.now() - new Date(u.lastLoginAt).getTime()
const m = Math.floor(diff / 60_000)
if (m < 1) return 'active'
if (m < 60) return `${m} min ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h} h ago`
const d = Math.floor(h / 24)
return `${d} d ago`
}
</script>
<template>
<div>
<PageHeader
eyebrow="Platform"
title="Operator team"
:subtitle="`${operators.length} platform admin${operators.length === 1 ? '' : 's'} · membership comes from the dezky-platform-admins Authentik group.`"
>
<template #actions>
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
<template #leading><UiIcon name="chevDown" :size="13" /></template>
Refresh
</UiButton>
<a href="https://auth.dezky.local/if/admin/" target="_blank" rel="noopener" class="link">
<UiButton variant="primary">
<template #leading><UiIcon name="external" :size="13" /></template>
Manage in Authentik
</UiButton>
</a>
</template>
</PageHeader>
<div class="stage">
<Card :pad="0">
<table v-if="operators.length">
<thead>
<tr>
<th>Member</th>
<th>Email</th>
<th>Tenants</th>
<th>Last seen</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="u in operators" :key="u._id">
<td class="member">
<Avatar :name="u.name" :size="28" />
<div>
<div class="name">{{ u.name }}</div>
<Mono dim>{{ u.authentikSubjectId.slice(0, 8) }}</Mono>
</div>
</td>
<td><Mono>{{ u.email }}</Mono></td>
<td>
<span v-if="u.tenantIds?.length"><Mono>{{ u.tenantIds.length }}</Mono></span>
<Mono v-else dim></Mono>
</td>
<td><Mono dim>{{ lastSeen(u) }}</Mono></td>
<td>
<Badge :tone="u.active ? 'ok' : 'neutral'" dot>{{ u.active ? 'active' : 'inactive' }}</Badge>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">
<Mono dim>// no platform admins found — add a user to the dezky-platform-admins group in Authentik</Mono>
</div>
</Card>
<div class="note">
<UiIcon name="shield" :size="13" />
<Mono dim>
Operator access is gated by membership in the <strong>dezky-platform-admins</strong> Authentik
group plus a token with audience <code>dezky-operator</code>. Both conditions must hold.
</Mono>
</div>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
.link { text-decoration: none; }
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: 12px 20px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
td.member { display: flex; align-items: center; gap: 12px; }
.name { font-weight: 500; font-size: 13px; }
.empty { padding: 40px 20px; text-align: center; }
.note {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 16px;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-mute);
}
.note code { font-family: var(--font-mono); color: var(--text-dim); padding: 1px 4px; border-radius: 3px; background: var(--surface); }
.note strong { color: var(--text); font-weight: 600; }
</style>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts"></script>
<template>
<OpPlaceholder
eyebrow="Commercial"
title="Reports"
icon="database"
body="Cohort analyses, churn, expansion revenue, partner-margin reports. Tracked as a follow-up after billing lands."
/>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts"></script>
<template>
<OpPlaceholder
eyebrow="Platform"
title="Platform settings"
icon="shield"
body="Global toggles: default plan, terms of service version, brand assets, retention windows, allowed regions. Currently configured via env vars and provisioning scripts."
/>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts"></script>
<template>
<OpPlaceholder
eyebrow="Customer"
title="Support queue"
icon="help"
body="Inbox of customer tickets, escalation paths, and SLA timers. We'll wire this up once we pick a ticketing backend (Zammad / Plain / Help Scout / DIY)."
/>
</template>
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { PlatformUser } from '~/types/user'
const { data: users, pending, refresh } = await useFetch<PlatformUser[]>('/api/users', {
default: () => [],
})
const search = ref('')
const view = ref<'all' | 'admins' | 'inactive'>('all')
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
return (users.value ?? []).filter((u) => {
if (view.value === 'admins' && !u.platformAdmin) return false
if (view.value === 'inactive' && u.active) return false
if (!q) return true
return u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
})
})
const counts = computed(() => ({
all: users.value?.length ?? 0,
admins: (users.value ?? []).filter((u) => u.platformAdmin).length,
inactive: (users.value ?? []).filter((u) => !u.active).length,
}))
function lastSeen(u: PlatformUser) {
if (!u.lastLoginAt) return '—'
const diff = Date.now() - new Date(u.lastLoginAt).getTime()
const d = Math.floor(diff / 86_400_000)
if (d > 0) return `${d} d ago`
const h = Math.floor(diff / 3_600_000)
if (h > 0) return `${h} h ago`
const m = Math.floor(diff / 60_000)
if (m > 0) return `${m} min ago`
return 'active'
}
</script>
<template>
<div>
<PageHeader
eyebrow="Identity"
title="Users (global)"
:subtitle="`${counts.all} users across all tenants. ${counts.admins} platform admin${counts.admins === 1 ? '' : 's'}.`"
>
<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="views">
<button :class="['chip', { on: view === 'all' }]" type="button" @click="view = 'all'">All <Mono dim>{{ counts.all }}</Mono></button>
<button :class="['chip', { on: view === 'admins' }]" type="button" @click="view = 'admins'">Admins <Mono dim>{{ counts.admins }}</Mono></button>
<button :class="['chip', { on: view === 'inactive' }]" type="button" @click="view = 'inactive'">Inactive <Mono dim>{{ counts.inactive }}</Mono></button>
</div>
<div class="search">
<UiIcon name="search" :size="13" />
<input v-model="search" placeholder="email, name…" type="text" />
</div>
</div>
<Card :pad="0">
<table v-if="filtered.length">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Tenants</th>
<th>Last login</th>
<th>Role</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="u in filtered" :key="u._id">
<td class="user">
<Avatar :name="u.name" :size="26" />
<div>
<div class="name">{{ u.name }}</div>
<Mono dim>{{ u.authentikSubjectId.slice(0, 12) }}</Mono>
</div>
</td>
<td><Mono>{{ u.email }}</Mono></td>
<td><Mono>{{ u.tenantIds?.length ?? 0 }}</Mono></td>
<td><Mono dim>{{ lastSeen(u) }}</Mono></td>
<td>
<Badge v-if="u.platformAdmin" tone="accent" dot>platform admin</Badge>
<Mono v-else dim>tenant user</Mono>
</td>
<td>
<Badge :tone="u.active ? 'ok' : 'neutral'" dot>{{ u.active ? 'active' : 'inactive' }}</Badge>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">
<Mono dim>// no users match the current filter</Mono>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.views { display: flex; gap: 6px; }
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
border-radius: 999px;
font-family: inherit;
cursor: pointer;
}
.chip:hover { color: var(--text); }
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
.chip.on :deep(.mono) { color: var(--bg); opacity: 0.7; }
.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: 280px;
}
.search input {
background: transparent;
border: 0;
outline: 0;
flex: 1;
font-family: inherit;
font-size: 12px;
color: var(--text);
}
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: 12px 20px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
td.user { display: flex; align-items: center; gap: 10px; }
.name { font-weight: 500; font-size: 13px; }
.empty { padding: 40px 20px; text-align: center; }
</style>