Files
Ronni Baslund 868a305539 feat(flags): real feature-flag system with bulk eval + operator UI
Real backend for the flags page (was pure mock). Built so it's ready for
the first risky rollout (likely the Stalwart JMAP client or the Stripe
billing engine).

services/platform-api:
- Flag schema (key, description, state, pct, scope.{plans, tenantSlugs,
  partnerSlugs, environments}, embedded history capped at 20)
- FlagsService with CRUD + evaluateAll(tenantSlug) → { key: bool }
  Eval algorithm:
    off  → false; on → true
    targeted → require non-empty scope (empty allowlist means "nobody"),
               then match every non-empty axis
    rollout  → match scope, then sha256(`${tenantId}:${key}`) % 100 < pct
  Hash-based rollout is deterministic: bumping pct only flips the new
  slice. Pure helpers (matchesScope, hasAnyScope, inRolloutBucket) are
  exported for future unit tests.
- FlagsController exposes GET /flags, GET /flags/:key, POST /flags/evaluate
  (JwtAuthGuard); POST/PATCH/DELETE require OperatorGuard. History entries
  capture the actor's email.
- SeedService idempotently creates 10 flag keys mapping to real Dezky
  concerns (jmap_native_v2, gdpr_export_v2, new_billing_engine, etc.).
  $setOnInsert so operator edits survive restarts.

apps/operator:
- 6 proxies: /api/flags index get/post, [key] get/patch/delete, evaluate post
- types/flag.ts with the shape that mirrors the backend
- pages/flags.vue: useFetch real list, row click opens FlagDetail,
  "New flag" opens NewFlagModal, scope summary column shows targeting
  at a glance
- FlagDetail.vue: side panel with segmented state, rollout slider with
  live "~N of M tenants" preview from /api/tenants, plan/tenant/env chip
  pickers, dirty-tracked Save, instant Kill-switch (PATCH state=off+pct=0),
  embedded change history
- NewFlagModal.vue: minimal create form (key + description). Everything
  else is configured in the detail panel afterward.
- CommandPalette: feature-flag rows now come from /api/flags instead of
  the dropped fixture, so newly-created flags are searchable immediately
- data/fixtures.ts: drop FLAGS / FeatureFlag exports (replaced by the
  real backend)

Smoke-tested end-to-end: list renders 10 seed flags, opening gdpr_export_v2
and flipping to rollout 25% then saving persists + adds a history entry,
kill-switch sets state=off in one click, /api/flags/evaluate returns the
correct booleans for the seeded tenant, same tenant gets the same answer
on consecutive evals (determinism), and creating + deleting a flag through
the UI roundtrips correctly.
2026-05-24 19:21:15 +02:00

370 lines
10 KiB
Vue

<script setup lang="ts">
import type { IconName } from './UiIcon.vue'
import type { Tenant } from '~/types/tenant'
import type { Partner } from '~/types/partner'
import type { Flag } from '~/types/flag'
import { INCIDENT } from '~/data/fixtures'
// Each row in the palette. `action` decides what happens on Enter / click.
// We try `to` first (a navigateTo) since most rows are navigation; `run` is
// the escape hatch for actions that aren't a route (open modal, toast, etc.).
interface Row {
id: string
groupLabel: string
title: string
subtitle?: string
icon?: IconName
badge?: string
to?: string
run?: () => void
}
const { isOpen, close } = useCommandPalette()
const { open: openImpersonation } = useImpersonation()
const { open: openIncident } = useIncidentModal()
const query = ref('')
const cursor = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
// Only fetch when the palette has been opened at least once, then refresh on
// every open so newly-created tenants/partners show up. SSR is disabled (the
// palette is client-only).
const { data: tenants, refresh: rT } = useLazyFetch<Tenant[]>('/api/tenants', { default: () => [], server: false })
const { data: partners, refresh: rP } = useLazyFetch<Partner[]>('/api/partners', { default: () => [], server: false })
const { data: flags, refresh: rF } = useLazyFetch<Flag[]>('/api/flags', { default: () => [], server: false })
const NAV: { id: string; label: string; icon: IconName; to: string }[] = [
{ id: 'n-overview', label: 'Overview', icon: 'home', to: '/' },
{ id: 'n-tenants', label: 'Tenants', icon: 'building', to: '/tenants' },
{ id: 'n-partners', label: 'Partners', icon: 'briefcase', to: '/partners' },
{ id: 'n-users', label: 'Users (global)', icon: 'users', to: '/users' },
{ id: 'n-support', label: 'Support', icon: 'help', to: '/support' },
{ id: 'n-billing', label: 'Platform billing', icon: 'card', to: '/billing' },
{ id: 'n-reports', label: 'Reports', icon: 'database', to: '/reports' },
{ id: 'n-infra', label: 'Infrastructure', icon: 'plug', to: '/infrastructure' },
{ id: 'n-flags', label: 'Feature flags', icon: 'shield', to: '/flags' },
{ id: 'n-audit', label: 'Audit log', icon: 'file', to: '/audit' },
{ id: 'n-team', label: 'Operator team', icon: 'users', to: '/operator-team' },
{ id: 'n-settings', label: 'Platform settings',icon: 'shield', to: '/settings' },
]
function tenantRows(): Row[] {
return (tenants.value ?? []).map((t) => ({
id: `t-${t._id}`,
groupLabel: 'Tenants',
title: t.name,
subtitle: `${t.domains?.[0] ?? t.slug} · ${t.plan} · ${t.status}`,
icon: 'building',
badge: t.status,
to: `/tenants/${t.slug}`,
}))
}
function partnerRows(): Row[] {
return (partners.value ?? []).map((p) => ({
id: `p-${p._id}`,
groupLabel: 'Partners',
title: p.name,
subtitle: `${p.domain} · ${p.customers} customers · ${p.marginPct}% margin`,
icon: 'briefcase',
badge: p.status,
to: `/partners/${p.slug}`,
}))
}
function flagRows(): Row[] {
return (flags.value ?? []).slice(0, 8).map((f) => ({
id: `f-${f.key}`,
groupLabel: 'Feature flags',
title: f.key,
subtitle: f.state === 'rollout' ? `rollout · ${f.pct}%` : f.state,
icon: 'plug',
badge: f.state,
to: '/flags',
}))
}
function navRows(): Row[] {
return NAV.map((n) => ({
id: n.id, groupLabel: 'Navigation', title: n.label, subtitle: 'navigate', icon: n.icon, to: n.to,
}))
}
function actionRows(): Row[] {
const firstTenant = tenants.value?.[0]
return [
{
id: 'a-impersonate',
groupLabel: 'Actions',
title: 'Impersonate user…',
subtitle: firstTenant ? `enter ${firstTenant.name} as a user` : 'no tenants yet',
icon: 'key',
run: () => firstTenant && openImpersonation(firstTenant),
},
{
id: 'a-incident',
groupLabel: 'Actions',
title: 'Open active incident',
subtitle: INCIDENT.title,
icon: 'bell',
run: () => openIncident(),
},
{
id: 'a-toggle-theme',
groupLabel: 'Actions',
title: 'Toggle theme',
subtitle: 'dark / light',
icon: 'shield',
run: () => {
const { state, setTheme } = useTweaks()
setTheme(state.value.theme === 'dark' ? 'light' : 'dark')
},
},
]
}
interface Group { label: string; items: Row[] }
const groups = computed<Group[]>(() => {
const q = query.value.trim().toLowerCase()
const all: Row[] = [
...tenantRows(),
...partnerRows(),
...flagRows(),
...navRows(),
...actionRows(),
]
const matches = q
? all.filter((r) => r.title.toLowerCase().includes(q) || (r.subtitle ?? '').toLowerCase().includes(q))
: all
const grouped = new Map<string, Row[]>()
for (const r of matches) {
const arr = grouped.get(r.groupLabel) ?? []
arr.push(r)
grouped.set(r.groupLabel, arr)
}
// Stable group order
const order = ['Tenants', 'Partners', 'Feature flags', 'Navigation', 'Actions']
return order
.filter((g) => grouped.has(g))
.map((g) => ({ label: g, items: (grouped.get(g) ?? []).slice(0, 8) }))
})
const flat = computed(() => groups.value.flatMap((g) => g.items))
watch(isOpen, async (v) => {
if (!v) return
query.value = ''
cursor.value = 0
await Promise.all([rT(), rP(), rF()])
await nextTick()
inputRef.value?.focus()
})
watch(query, () => {
cursor.value = 0
})
function activate(row?: Row) {
if (!row) return
if (row.run) row.run()
if (row.to) navigateTo(row.to)
close()
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
close()
}
if (e.key === 'ArrowDown') {
e.preventDefault()
cursor.value = Math.min(cursor.value + 1, Math.max(0, flat.value.length - 1))
}
if (e.key === 'ArrowUp') {
e.preventDefault()
cursor.value = Math.max(0, cursor.value - 1)
}
if (e.key === 'Enter') {
e.preventDefault()
activate(flat.value[cursor.value])
}
}
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="palette-backdrop" @click="close">
<div class="palette" role="dialog" aria-label="Command palette" @click.stop>
<div class="input-row">
<UiIcon name="search" :size="14" />
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search tenants, partners, actions…"
autocomplete="off"
@keydown="onKey"
/>
<span class="kbd">ESC</span>
</div>
<div class="results">
<template v-if="groups.length">
<div v-for="g in groups" :key="g.label" class="group">
<div class="group-label">{{ g.label }}</div>
<button
v-for="item in g.items"
:key="item.id"
type="button"
:class="['row', { active: item.id === flat[cursor]?.id }]"
@mouseenter="cursor = flat.findIndex((x) => x.id === item.id)"
@click="activate(item)"
>
<span class="icon-tile">
<UiIcon v-if="item.icon" :name="item.icon" :size="13" />
</span>
<span class="row-body">
<span class="row-title">{{ item.title }}</span>
<span v-if="item.subtitle" class="row-sub">{{ item.subtitle }}</span>
</span>
<Mono v-if="item.badge" dim>{{ item.badge }}</Mono>
</button>
</div>
</template>
<div v-else class="empty">
<Mono dim>// no matches for "{{ query }}"</Mono>
</div>
</div>
<footer>
<Mono dim>{{ flat.length }} {{ flat.length === 1 ? 'result' : 'results' }}</Mono>
<Mono dim> navigate · open · esc close</Mono>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.palette-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 15vh 24px 24px 24px;
z-index: 200;
}
.palette {
width: 100%;
max-width: 640px;
background: var(--elevated);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
max-height: 70vh;
overflow: hidden;
}
.input-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
color: var(--text-mute);
}
.input-row input {
flex: 1;
border: 0;
outline: 0;
background: transparent;
font-family: inherit;
font-size: 15px;
color: var(--text);
}
.kbd {
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 6px;
background: var(--surface);
border-radius: 3px;
color: var(--text-mute);
letter-spacing: 0.04em;
}
.results {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.group { margin-bottom: 4px; }
.group-label {
padding: 10px 14px 4px 14px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 500;
color: var(--text-mute);
}
.row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 14px;
background: transparent;
border: 0;
border-left: 2px solid transparent;
border-radius: 6px;
color: var(--text);
font-family: inherit;
cursor: pointer;
text-align: left;
}
.row.active {
background: rgba(212, 255, 58, 0.08);
border-left-color: var(--accent);
}
.icon-tile {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
flex-shrink: 0;
}
.row.active .icon-tile { color: var(--accent); }
.row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.row-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row-sub {
font-size: 11px;
color: var(--text-mute);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty { padding: 40px 18px; text-align: center; }
footer {
padding: 8px 14px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
</style>