Files
dezky/apps/operator/pages/index.vue
T
Ronni Baslund c71e782dc0 feat(operator): command palette, impersonation, incident, tweaks (O.8)
- CommandPalette + useCommandPalette: ⌘K opens a search-and-jump panel over
  real tenants/partners + fixture flags + nav + actions. Arrow keys + Enter
  navigate, Escape/backdrop close. Recents are intentionally omitted for now;
  add when there's something to recent over.
- Impersonation stub: useImpersonation + ImpersonationModal + ImpersonationBanner.
  Modal opens from tenant detail and from the palette. Banner stays at the top
  of the shell until exited. No real OBO token is minted — wiring OAuth Token
  Exchange is tracked as a follow-up.
- IncidentModal + useIncidentModal: opened from the Overview and Infrastructure
  incident banners, renders the mock INCIDENT data with metrics, timeline and
  draft composer.
- TweaksPanel + useTweaks: floating bottom-right panel for theme (dark/light),
  density (comfy/compact), env badge (prod/staging/dev). Saved to localStorage.
- Theme/density apply via [data-theme] + [data-density] overrides in
  tokens.css. Topbar env badge now reads from useTweaks instead of a prop.
- Layout wires ⌘K + ⌘[ at the document level and mounts the palette + modals
  + banner + tweaks panel once for all pages.
2026-05-24 08:34:34 +02:00

349 lines
12 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 { SERVICES, INCIDENT, OP_AUDIT } 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 { open: openIncident } = useIncidentModal()
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="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>streaming · mock</Mono>
</div>
</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>
</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;
}
.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>