114b419a69
Every page header's Refresh button rendered a downward chevron because the icon set had no refresh glyph. Added a circular-arrow 'refresh' icon to UiIcon and pointed all seven Refresh buttons (Overview, Tenants, Partners, Users, Operator team, Audit, Infrastructure) at it.
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="refresh" :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>
|