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:
+4
-1
@@ -29,8 +29,11 @@ logs/
|
|||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Docker volumes data (when bind-mounted)
|
# Docker volumes data (when bind-mounted) at the infra layer
|
||||||
data/
|
data/
|
||||||
|
# But keep app-level data/ dirs — operator carries mock fixtures there.
|
||||||
|
!apps/*/data/
|
||||||
|
!apps/*/data/**
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ label: string; value: string; tone?: 'ok' | 'warn' | 'bad' }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="label">{{ label }}</div>
|
||||||
|
<div class="value" :data-tone="tone">{{ value }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cell { min-width: 0; }
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.value[data-tone='ok'] { color: var(--ok); }
|
||||||
|
.value[data-tone='warn'] { color: var(--warn); }
|
||||||
|
.value[data-tone='bad'] { color: var(--bad); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { IconName } from './UiIcon.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
eyebrow?: string
|
||||||
|
icon: IconName
|
||||||
|
body: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="placeholder">
|
||||||
|
<PageHeader :eyebrow="eyebrow" :title="title" />
|
||||||
|
<div class="frame">
|
||||||
|
<div class="empty">
|
||||||
|
<div class="icon-tile">
|
||||||
|
<UiIcon :name="icon" :size="22" />
|
||||||
|
</div>
|
||||||
|
<div class="title">{{ title }}</div>
|
||||||
|
<p>{{ body }}</p>
|
||||||
|
<Mono dim>// implementation pending — see OPERATOR-PLAN.md follow-ups</Mono>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.frame { padding: 32px 40px 64px 40px; }
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 64px 32px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.icon-tile {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 380px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
delta?: string
|
||||||
|
deltaTone?: 'up' | 'down'
|
||||||
|
hint?: string
|
||||||
|
}>(),
|
||||||
|
{ deltaTone: 'up' },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="stat">
|
||||||
|
<Eyebrow>{{ label }}</Eyebrow>
|
||||||
|
<div class="value">{{ value }}</div>
|
||||||
|
<div v-if="delta || hint" class="meta">
|
||||||
|
<span v-if="delta" class="delta" :data-tone="deltaTone">{{ delta }}</span>
|
||||||
|
<Mono v-if="hint" dim>{{ hint }}</Mono>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stat { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 26px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.delta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.delta[data-tone='up'] { background: rgba(31, 138, 91, 0.12); color: var(--ok); }
|
||||||
|
.delta[data-tone='down'] { background: rgba(226, 48, 48, 0.12); color: var(--bad); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Visual-only fixtures for screens we haven't wired to a real backend yet
|
||||||
|
// (infrastructure, feature flags, audit log, active incident, operator team
|
||||||
|
// extras). Real data sources are GET /tenants, /partners, /users — anything
|
||||||
|
// derivable from those should NOT live here. See OPERATOR-PLAN.md follow-ups
|
||||||
|
// for the path from each fixture to a real implementation.
|
||||||
|
|
||||||
|
export type ServiceStatus = 'ok' | 'warn' | 'bad'
|
||||||
|
export interface PlatformService {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
status: ServiceStatus
|
||||||
|
uptime: number // percent, 30d
|
||||||
|
p95: number // ms
|
||||||
|
err: number // percent
|
||||||
|
last: string // human duration since last incident
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVICES: PlatformService[] = [
|
||||||
|
{ id: 'mail', name: 'Stalwart', role: 'Mail · IMAP/JMAP/SMTP', status: 'ok', uptime: 99.99, p95: 42, err: 0.002, last: '—' },
|
||||||
|
{ id: 'files', name: 'OCIS', role: 'Files · OwnCloud Infinite', status: 'ok', uptime: 99.97, p95: 88, err: 0.004, last: '11 d ago' },
|
||||||
|
{ id: 'video', name: 'Jitsi', role: 'Video meetings', status: 'ok', uptime: 99.91, p95: 124, err: 0.018, last: '4 d ago' },
|
||||||
|
{ id: 'chat', name: 'Zulip', role: 'Team chat', status: 'ok', uptime: 99.99, p95: 35, err: 0.001, last: '—' },
|
||||||
|
{ id: 'auth', name: 'Authentik', role: 'Identity · SSO · MFA', status: 'warn', uptime: 99.94, p95: 412, err: 0.052, last: 'active' },
|
||||||
|
{ id: 'db', name: 'PostgreSQL', role: 'Primary database', status: 'ok', uptime: 99.99, p95: 8, err: 0, last: '—' },
|
||||||
|
{ id: 'obj', name: 'Object storage',role: 'S3-compatible · Hetzner', status: 'ok', uptime: 99.99, p95: 22, err: 0.001, last: '—' },
|
||||||
|
{ id: 'cdn', name: 'Cloudflare', role: 'CDN · WAF', status: 'ok', uptime: 100, p95: 18, err: 0, last: '—' },
|
||||||
|
{ id: 'smtp', name: 'Outbound SMTP', role: 'Email delivery (Postmark)', status: 'ok', uptime: 99.95, p95: 280, err: 0, last: '3 d ago' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface ActiveIncident {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
severity: 'P1' | 'P2' | 'P3'
|
||||||
|
started: string
|
||||||
|
duration: string
|
||||||
|
affected: string
|
||||||
|
state: 'investigating' | 'identified' | 'monitoring'
|
||||||
|
ic: string
|
||||||
|
updates: { t: string; who: string; msg: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INCIDENT: ActiveIncident = {
|
||||||
|
id: 'INC-2026-018',
|
||||||
|
title: 'Authentik · elevated SSO login latency',
|
||||||
|
severity: 'P2',
|
||||||
|
started: '14:18',
|
||||||
|
duration: '42 min',
|
||||||
|
affected: 'Login latency p95 above 400ms · 12 tenants impacted',
|
||||||
|
state: 'investigating',
|
||||||
|
ic: 'Mikkel Nørgaard',
|
||||||
|
updates: [
|
||||||
|
{ t: '15:00', who: 'Mikkel N.', msg: 'Pod restart deployed, monitoring' },
|
||||||
|
{ t: '14:36', who: 'auto', msg: 'Page sent to on-call (Mikkel)' },
|
||||||
|
{ t: '14:22', who: 'Anne B.', msg: 'Confirmed: Postgres conn pool exhaustion on auth-db-2' },
|
||||||
|
{ t: '14:18', who: 'auto', msg: 'Alert: authentik p95 > 400ms for 5m · 12 tenants impacted' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlagState = 'on' | 'off' | 'rollout' | 'targeted'
|
||||||
|
export interface FeatureFlag {
|
||||||
|
key: string
|
||||||
|
state: FlagState
|
||||||
|
pct: number
|
||||||
|
scope: string
|
||||||
|
modified: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FLAGS: FeatureFlag[] = [
|
||||||
|
{ key: 'jmap_native_v2', state: 'rollout', pct: 50, scope: 'Business+ · 38 tenants', modified: 'Anne · 2 d ago' },
|
||||||
|
{ key: 'oci_versioning', state: 'on', pct: 100, scope: 'all tenants', modified: 'Anne · 14 d ago' },
|
||||||
|
{ key: 'jitsi_recording_e2ee', state: 'targeted', pct: 0, scope: 'allowlist · 3 tenants', modified: 'Mikkel · 5 d ago' },
|
||||||
|
{ key: 'new_billing_engine', state: 'rollout', pct: 25, scope: '12 tenants', modified: 'Anne · today' },
|
||||||
|
{ key: 'gdpr_export_v2', state: 'off', pct: 0, scope: 'kill-switch', modified: 'Sofie · 21 d ago' },
|
||||||
|
{ key: 'whitelabel_cssprops', state: 'on', pct: 100, scope: 'partners', modified: 'Anne · 1 mo ago' },
|
||||||
|
{ key: 'audit_log_streaming', state: 'on', pct: 100, scope: 'Enterprise', modified: 'Mikkel · 8 d ago' },
|
||||||
|
{ key: 'zulip_topic_threading', state: 'rollout', pct: 75, scope: '63 tenants', modified: 'Sofie · 3 d ago' },
|
||||||
|
{ key: 'tos_2026_acceptance', state: 'on', pct: 100, scope: 'all tenants', modified: 'Anne · 6 d ago' },
|
||||||
|
{ key: 'beta_ai_summaries', state: 'off', pct: 0, scope: 'killed', modified: 'Anne · 1 mo ago' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export type AuditTone = 'info' | 'warn' | 'bad'
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: string
|
||||||
|
when: string
|
||||||
|
actor: string
|
||||||
|
role: string
|
||||||
|
action: string
|
||||||
|
target: string
|
||||||
|
tenant: string
|
||||||
|
ip: string
|
||||||
|
tone: AuditTone
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OP_AUDIT: AuditEntry[] = [
|
||||||
|
{ id: 'op_8821', when: '15:02:11', actor: 'Anne Baslund', role: 'platform admin', action: 'feature_flag.rollout', target: 'jmap_native_v2 · 50%', tenant: '—', ip: '10.0.4.18', tone: 'info' },
|
||||||
|
{ id: 'op_8820', when: '14:58:42', actor: 'Mikkel Nørgaard', role: 'engineer', action: 'service.pod_restart', target: 'authentik-worker-3', tenant: '—', ip: '10.0.4.21', tone: 'warn' },
|
||||||
|
{ id: 'op_8819', when: '14:48:02', actor: 'Sofie Lindberg', role: 'ops', action: 'tenant.impersonate', target: 'oliver@bygherre.dk', tenant: 'Bygherre Cloud', ip: '10.0.4.04', tone: 'info' },
|
||||||
|
{ id: 'op_8818', when: '14:36:00', actor: 'system', role: 'auto', action: 'oncall.paged', target: 'Mikkel Nørgaard', tenant: '—', ip: '—', tone: 'warn' },
|
||||||
|
{ id: 'op_8817', when: '14:18:00', actor: 'system', role: 'auto', action: 'alert.triggered', target: 'authentik p95 > 400ms', tenant: '—', ip: '—', tone: 'bad' },
|
||||||
|
{ id: 'op_8816', when: '13:21:55', actor: 'Anne Baslund', role: 'platform admin', action: 'tenant.refund_issued', target: 'INV-0480 · 980 DKK', tenant: 'Vester Foods', ip: '10.0.4.18', tone: 'info' },
|
||||||
|
{ id: 'op_8815', when: '12:09:30', actor: 'Sofie Lindberg', role: 'ops', action: 'tenant.suspended', target: 'København Kalkulator', tenant: 'København Kalkulator', ip: '10.0.4.04', tone: 'warn' },
|
||||||
|
{ id: 'op_8814', when: '11:44:00', actor: 'Anne Baslund', role: 'platform admin', action: 'partner.created', target: 'Klaussen Digital · invited', tenant: '—', ip: '10.0.4.18', tone: 'info' },
|
||||||
|
{ id: 'op_8813', when: '10:55:41', actor: 'system', role: 'auto', action: 'invoice.past_due', target: 'INV-0522 · 2.940 DKK · 21 d', tenant: 'Bygherre Cloud', ip: '—', tone: 'bad' },
|
||||||
|
{ id: 'op_8812', when: '10:12:08', actor: 'Mikkel Nørgaard', role: 'engineer', action: 'feature_flag.created', target: 'beta_ai_summaries', tenant: '—', ip: '10.0.4.21', tone: 'info' },
|
||||||
|
{ id: 'op_8811', when: '09:30:00', actor: 'Anne Baslund', role: 'platform admin', action: 'tos.published', target: 'v2026.05 · all tenants', tenant: '—', ip: '10.0.4.18', tone: 'info' },
|
||||||
|
]
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+304
-95
@@ -1,137 +1,346 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// O.4 deliverable: real shell wrapping the placeholder dashboard. The smoke
|
import type { Tenant } from '~/types/tenant'
|
||||||
// test from O.3 stays so we can keep verifying the audience chain after
|
import type { Partner } from '~/types/partner'
|
||||||
// every restart. Real Overview content lands in O.7.
|
import type { PlatformUser } from '~/types/user'
|
||||||
|
import { SERVICES, INCIDENT, OP_AUDIT } from '~/data/fixtures'
|
||||||
|
|
||||||
const { user } = useOidcAuth()
|
const { data: tenants, pending: tp, refresh: rT } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
|
||||||
const smokeResult = ref<string | null>(null)
|
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
|
||||||
const smokeBusy = ref(false)
|
const { data: users, pending: up, refresh: rU } = await useFetch<PlatformUser[]>('/api/users', { default: () => [] })
|
||||||
|
|
||||||
async function createTestPartner() {
|
const pending = computed(() => tp.value || pp.value || up.value)
|
||||||
smokeBusy.value = true
|
|
||||||
smokeResult.value = null
|
async function refresh() {
|
||||||
try {
|
await Promise.all([rT(), rP(), rU()])
|
||||||
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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Overview"
|
eyebrow="Operator · operator.dezky.local"
|
||||||
:title="`Hi, ${user?.userInfo?.name || user?.userName || 'operator'}.`"
|
title="Platform overview"
|
||||||
subtitle="O.4 scaffolding · sidebar + topbar + design tokens wired up. Real dashboard tiles, metrics and incident panel land in O.7."
|
:subtitle="`${stats.tenants} tenants · ${stats.partners} partners · ${stats.users} platform users`"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<UiButton variant="secondary">
|
<UiButton variant="secondary" :disabled="pending" @click="refresh">
|
||||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
<template #leading><UiIcon name="chevDown" :size="13" /></template>
|
||||||
Docs
|
Refresh
|
||||||
</UiButton>
|
</UiButton>
|
||||||
|
<NuxtLink to="/tenants" class="primary-link">
|
||||||
<UiButton variant="primary">
|
<UiButton variant="primary">
|
||||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||||
New tenant
|
New tenant
|
||||||
</UiButton>
|
</UiButton>
|
||||||
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="stage">
|
<div class="stage">
|
||||||
<Card>
|
<button v-if="incidentActive" class="incident" type="button">
|
||||||
<div class="row">
|
<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>
|
<div>
|
||||||
<h2>Smoke test · POST /partners</h2>
|
<Eyebrow>Live · platform-wide</Eyebrow>
|
||||||
<p>
|
<div class="cap">Activity</div>
|
||||||
Forwards your access token to platform-api. Operator-scoped tokens succeed
|
</div>
|
||||||
(200 first time, 409 thereafter). Customer-portal tokens return 403.
|
<div class="streaming">
|
||||||
</p>
|
<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>
|
||||||
<UiButton variant="primary" :disabled="smokeBusy" @click="createTestPartner">
|
|
||||||
{{ smokeBusy ? 'Calling…' : 'Create partner' }}
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
</div>
|
||||||
<pre v-if="smokeResult" class="result">{{ smokeResult }}</pre>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<div class="side">
|
||||||
<h2 class="cap">Session</h2>
|
<Card :pad="0">
|
||||||
<div class="meta">
|
<div class="head">
|
||||||
<div class="kv"><Eyebrow>subject</Eyebrow><Mono>{{ user?.userName }}</Mono></div>
|
<div>
|
||||||
<div class="kv"><Eyebrow>email</Eyebrow><Mono>{{ user?.userInfo?.email }}</Mono></div>
|
<Eyebrow>Status · platform-wide</Eyebrow>
|
||||||
<div class="kv">
|
<div class="cap">{{ stats.active }} / {{ stats.tenants }} active</div>
|
||||||
<Eyebrow>groups</Eyebrow>
|
</div>
|
||||||
<span class="groups">
|
</div>
|
||||||
<Badge
|
<div class="status-rows">
|
||||||
v-for="g in (user?.userInfo as { groups?: string[] } | undefined)?.groups || []"
|
<div class="status-row">
|
||||||
:key="g"
|
<Mono>active</Mono>
|
||||||
:tone="g === 'dezky-platform-admins' ? 'accent' : 'neutral'"
|
<div class="meter"><div class="meter-fill ok" :style="{ width: stats.tenants ? `${(stats.active / stats.tenants) * 100}%` : '0%' }" /></div>
|
||||||
>{{ g }}</Badge>
|
<Mono>{{ stats.active }}</Mono>
|
||||||
</span>
|
</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>
|
||||||
<div class="kv"><Eyebrow>token aud</Eyebrow><Badge tone="invert">dezky-operator</Badge></div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stage {
|
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
padding: 24px 40px 64px 40px;
|
|
||||||
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
max-width: 1100px;
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
.pill {
|
||||||
.row { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
h2 {
|
gap: 8px;
|
||||||
font-family: var(--font-display);
|
padding: 4px 10px;
|
||||||
font-weight: 600;
|
background: var(--bad);
|
||||||
font-size: 17px;
|
color: #fff;
|
||||||
letter-spacing: -0.01em;
|
border-radius: 4px;
|
||||||
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;
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11.5px;
|
font-weight: 700;
|
||||||
color: var(--text-dim);
|
font-size: 11px;
|
||||||
white-space: pre-wrap;
|
letter-spacing: 0.08em;
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.vitals {
|
||||||
font-family: var(--font-display);
|
display: grid;
|
||||||
font-weight: 600;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
font-size: 17px;
|
gap: 1px;
|
||||||
letter-spacing: -0.01em;
|
background: var(--border);
|
||||||
margin: 0 0 14px 0;
|
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; }
|
.grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
|
||||||
.kv { display: flex; align-items: center; gap: 16px; }
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
.kv :first-child { width: 110px; flex-shrink: 0; }
|
|
||||||
.groups { display: flex; gap: 6px; flex-wrap: wrap; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => platformApi(event, '/users'))
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Shape returned by /api/users — matches the User schema on platform-api.
|
||||||
|
|
||||||
|
export interface PlatformUser {
|
||||||
|
_id: string
|
||||||
|
authentikSubjectId: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
active: boolean
|
||||||
|
platformAdmin: boolean
|
||||||
|
tenantIds: string[]
|
||||||
|
lastLoginAt?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
+22
-15
@@ -430,22 +430,29 @@ forward as bearer to platform-api.
|
|||||||
- MRR aggregation deferred until Subscription gains real pricing (see
|
- MRR aggregation deferred until Subscription gains real pricing (see
|
||||||
follow-ups). For now `customers` is just a count of attached tenants.
|
follow-ups). For now `customers` is just a count of attached tenants.
|
||||||
|
|
||||||
### O.7 · Visual-only screens (mock fixtures)
|
### O.7 · Visual-only screens (mock fixtures) ✓
|
||||||
|
|
||||||
- [ ] `data/*.ts` — typed mock fixtures (tenants-extra, partners-extra,
|
- [x] `data/fixtures.ts` — typed mock fixtures (SERVICES, INCIDENT, FLAGS,
|
||||||
services, incident, flags, audit, team)
|
OP_AUDIT). Tenant/partner/user extras are NOT mocked — those screens
|
||||||
- [ ] `pages/index.vue` — Overview dashboard
|
pull from the real backend.
|
||||||
- [ ] `pages/operator-team.vue` — real backend (Users where
|
- [x] `pages/index.vue` — Overview dashboard: KPIs from real tenants/partners
|
||||||
`platformAdmin === true`)
|
/users + status meter + recent + needs-follow-up tables, with mock
|
||||||
- [ ] `pages/users.vue` — global users, real read
|
activity stream + incident banner overlay.
|
||||||
- [ ] `pages/infrastructure.vue` — service health (mock for now;
|
- [x] `pages/operator-team.vue` — real `GET /users` filtered to
|
||||||
docker health check integration is a follow-up)
|
`platformAdmin === true`.
|
||||||
- [ ] `pages/flags.vue` — feature flags (mock)
|
- [x] `pages/users.vue` — real `GET /users` with All / Admins / Inactive
|
||||||
- [ ] `pages/audit.vue` — global audit (mock)
|
views and search.
|
||||||
- [ ] `pages/support.vue` — placeholder
|
- [x] `pages/infrastructure.vue` — service health (mock SERVICES);
|
||||||
- [ ] `pages/billing.vue` — placeholder
|
docker healthcheck + Prometheus wiring is a follow-up.
|
||||||
- [ ] `pages/reports.vue` — placeholder
|
- [x] `pages/flags.vue` — feature flags (mock FLAGS).
|
||||||
- [ ] `pages/settings.vue` — placeholder
|
- [x] `pages/audit.vue` — cross-tenant audit (mock OP_AUDIT) with search.
|
||||||
|
- [x] `pages/support.vue` — `OpPlaceholder` stub.
|
||||||
|
- [x] `pages/billing.vue` — `OpPlaceholder` stub.
|
||||||
|
- [x] `pages/reports.vue` — `OpPlaceholder` stub.
|
||||||
|
- [x] `pages/settings.vue` — `OpPlaceholder` stub.
|
||||||
|
- [x] Shared bits added: `components/Stat.vue`, `components/MetricCell.vue`,
|
||||||
|
`components/OpPlaceholder.vue`, `server/api/users/index.get.ts`,
|
||||||
|
`types/user.ts`.
|
||||||
|
|
||||||
### O.8 · Interactions
|
### O.8 · Interactions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user