diff --git a/.gitignore b/.gitignore index 4e61bac..61cd4ad 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,11 @@ logs/ *.swo .DS_Store -# Docker volumes data (when bind-mounted) +# Docker volumes data (when bind-mounted) at the infra layer data/ +# But keep app-level data/ dirs — operator carries mock fixtures there. +!apps/*/data/ +!apps/*/data/** # Coverage coverage/ diff --git a/apps/operator/components/MetricCell.vue b/apps/operator/components/MetricCell.vue new file mode 100644 index 0000000..8bd0f44 --- /dev/null +++ b/apps/operator/components/MetricCell.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/apps/operator/components/OpPlaceholder.vue b/apps/operator/components/OpPlaceholder.vue new file mode 100644 index 0000000..5479837 --- /dev/null +++ b/apps/operator/components/OpPlaceholder.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/apps/operator/components/Stat.vue b/apps/operator/components/Stat.vue new file mode 100644 index 0000000..18ec80d --- /dev/null +++ b/apps/operator/components/Stat.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/apps/operator/data/fixtures.ts b/apps/operator/data/fixtures.ts new file mode 100644 index 0000000..cd9fb68 --- /dev/null +++ b/apps/operator/data/fixtures.ts @@ -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' }, +] diff --git a/apps/operator/pages/audit.vue b/apps/operator/pages/audit.vue new file mode 100644 index 0000000..0024039 --- /dev/null +++ b/apps/operator/pages/audit.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/apps/operator/pages/billing.vue b/apps/operator/pages/billing.vue new file mode 100644 index 0000000..bd65e64 --- /dev/null +++ b/apps/operator/pages/billing.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/operator/pages/flags.vue b/apps/operator/pages/flags.vue new file mode 100644 index 0000000..e201954 --- /dev/null +++ b/apps/operator/pages/flags.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/operator/pages/index.vue b/apps/operator/pages/index.vue index af1318e..768f8c3 100644 --- a/apps/operator/pages/index.vue +++ b/apps/operator/pages/index.vue @@ -1,137 +1,346 @@ diff --git a/apps/operator/pages/infrastructure.vue b/apps/operator/pages/infrastructure.vue new file mode 100644 index 0000000..4615bca --- /dev/null +++ b/apps/operator/pages/infrastructure.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/apps/operator/pages/operator-team.vue b/apps/operator/pages/operator-team.vue new file mode 100644 index 0000000..e9d683d --- /dev/null +++ b/apps/operator/pages/operator-team.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/apps/operator/pages/reports.vue b/apps/operator/pages/reports.vue new file mode 100644 index 0000000..2346fef --- /dev/null +++ b/apps/operator/pages/reports.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/operator/pages/settings.vue b/apps/operator/pages/settings.vue new file mode 100644 index 0000000..908b290 --- /dev/null +++ b/apps/operator/pages/settings.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/operator/pages/support.vue b/apps/operator/pages/support.vue new file mode 100644 index 0000000..032ebd1 --- /dev/null +++ b/apps/operator/pages/support.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/operator/pages/users.vue b/apps/operator/pages/users.vue new file mode 100644 index 0000000..b3829e7 --- /dev/null +++ b/apps/operator/pages/users.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/apps/operator/server/api/users/index.get.ts b/apps/operator/server/api/users/index.get.ts new file mode 100644 index 0000000..4662e23 --- /dev/null +++ b/apps/operator/server/api/users/index.get.ts @@ -0,0 +1,3 @@ +import { platformApi } from '~~/server/utils/platform-api' + +export default defineEventHandler((event) => platformApi(event, '/users')) diff --git a/apps/operator/types/user.ts b/apps/operator/types/user.ts new file mode 100644 index 0000000..b018610 --- /dev/null +++ b/apps/operator/types/user.ts @@ -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 +} diff --git a/docs/OPERATOR-PLAN.md b/docs/OPERATOR-PLAN.md index 3494164..e34b360 100644 --- a/docs/OPERATOR-PLAN.md +++ b/docs/OPERATOR-PLAN.md @@ -430,22 +430,29 @@ forward as bearer to platform-api. - MRR aggregation deferred until Subscription gains real pricing (see 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, - services, incident, flags, audit, team) -- [ ] `pages/index.vue` — Overview dashboard -- [ ] `pages/operator-team.vue` — real backend (Users where - `platformAdmin === true`) -- [ ] `pages/users.vue` — global users, real read -- [ ] `pages/infrastructure.vue` — service health (mock for now; - docker health check integration is a follow-up) -- [ ] `pages/flags.vue` — feature flags (mock) -- [ ] `pages/audit.vue` — global audit (mock) -- [ ] `pages/support.vue` — placeholder -- [ ] `pages/billing.vue` — placeholder -- [ ] `pages/reports.vue` — placeholder -- [ ] `pages/settings.vue` — placeholder +- [x] `data/fixtures.ts` — typed mock fixtures (SERVICES, INCIDENT, FLAGS, + OP_AUDIT). Tenant/partner/user extras are NOT mocked — those screens + pull from the real backend. +- [x] `pages/index.vue` — Overview dashboard: KPIs from real tenants/partners + /users + status meter + recent + needs-follow-up tables, with mock + activity stream + incident banner overlay. +- [x] `pages/operator-team.vue` — real `GET /users` filtered to + `platformAdmin === true`. +- [x] `pages/users.vue` — real `GET /users` with All / Admins / Inactive + views and search. +- [x] `pages/infrastructure.vue` — service health (mock SERVICES); + docker healthcheck + Prometheus wiring is a follow-up. +- [x] `pages/flags.vue` — feature flags (mock FLAGS). +- [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