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

433 lines
13 KiB
Vue

<script setup lang="ts">
// Right-anchored side panel for editing a single flag. Opens from a row click
// on /flags. Mirrors the NotificationDrawer/IncidentModal dismissal pattern
// (Teleport scrim + Escape + route change) and the segmented-state UI from
// the operator design.
import type { Flag, FlagState } from '~/types/flag'
import type { Tenant } from '~/types/tenant'
const props = defineProps<{ flag: Flag | null; tenants: Tenant[] }>()
const emit = defineEmits<{
close: []
saved: [Flag]
deleted: [string]
}>()
const route = useRoute()
// Local working copy so unsaved edits aren't visible elsewhere. Reset every
// time `flag` changes (opening a different row, or re-opening after save).
const draft = ref({
description: '',
state: 'off' as FlagState,
pct: 0,
plans: [] as string[],
tenantSlugs: [] as string[],
partnerSlugs: [] as string[],
environments: [] as ('prod' | 'staging' | 'dev')[],
note: '',
})
watch(
() => props.flag,
(f) => {
if (!f) return
draft.value = {
description: f.description,
state: f.state,
pct: f.pct,
plans: [...f.scope.plans],
tenantSlugs: [...f.scope.tenantSlugs],
partnerSlugs: [...f.scope.partnerSlugs],
environments: [...f.scope.environments],
note: '',
}
},
{ immediate: true },
)
watch(() => route.path, () => {
if (props.flag) emit('close')
})
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.flag) emit('close')
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
const STATES: FlagState[] = ['off', 'targeted', 'rollout', 'on']
const ALL_PLANS = ['mvp', 'pro', 'enterprise']
const ALL_ENVS: ('prod' | 'staging' | 'dev')[] = ['prod', 'staging', 'dev']
const totalTenants = computed(() => props.tenants.length)
const rolloutCount = computed(() => Math.round((totalTenants.value * draft.value.pct) / 100))
const dirty = computed(() => {
if (!props.flag) return false
const f = props.flag
const d = draft.value
return (
d.description !== f.description ||
d.state !== f.state ||
d.pct !== f.pct ||
!arrEq(d.plans, f.scope.plans) ||
!arrEq(d.tenantSlugs, f.scope.tenantSlugs) ||
!arrEq(d.partnerSlugs, f.scope.partnerSlugs) ||
!arrEq(d.environments, f.scope.environments)
)
})
function arrEq(a: string[], b: string[]) {
if (a.length !== b.length) return false
const sa = [...a].sort()
const sb = [...b].sort()
return sa.every((v, i) => v === sb[i])
}
const busy = ref(false)
const error = ref<string | null>(null)
function togglePlan(p: string) {
draft.value.plans = draft.value.plans.includes(p)
? draft.value.plans.filter((x) => x !== p)
: [...draft.value.plans, p]
}
function toggleEnv(e: 'prod' | 'staging' | 'dev') {
draft.value.environments = draft.value.environments.includes(e)
? draft.value.environments.filter((x) => x !== e)
: [...draft.value.environments, e]
}
function toggleTenant(slug: string) {
draft.value.tenantSlugs = draft.value.tenantSlugs.includes(slug)
? draft.value.tenantSlugs.filter((x) => x !== slug)
: [...draft.value.tenantSlugs, slug]
}
async function save() {
if (!props.flag || !dirty.value) return
busy.value = true
error.value = null
try {
const updated = await $fetch<Flag>(`/api/flags/${props.flag.key}`, {
method: 'PATCH',
body: {
description: draft.value.description,
state: draft.value.state,
pct: draft.value.pct,
scope: {
plans: draft.value.plans,
tenantSlugs: draft.value.tenantSlugs,
partnerSlugs: draft.value.partnerSlugs,
environments: draft.value.environments,
},
note: draft.value.note || undefined,
},
})
emit('saved', updated)
} catch (e) {
const err = e as { data?: { data?: { message?: string }; message?: string } }
error.value = err.data?.data?.message || err.data?.message || 'Save failed'
} finally {
busy.value = false
}
}
async function killSwitch() {
if (!props.flag) return
busy.value = true
error.value = null
try {
const updated = await $fetch<Flag>(`/api/flags/${props.flag.key}`, {
method: 'PATCH',
body: { state: 'off', pct: 0, note: 'kill-switch' },
})
emit('saved', updated)
} catch (e) {
const err = e as { data?: { message?: string } }
error.value = err.data?.message || 'Kill-switch failed'
} finally {
busy.value = false
}
}
function fmtDate(s: string) {
return new Date(s).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' })
}
</script>
<template>
<Teleport to="body">
<Transition name="drawer-fade">
<div v-if="flag" class="backdrop" @click="emit('close')" />
</Transition>
<Transition name="drawer-slide">
<aside v-if="flag" class="drawer" role="dialog" :aria-label="flag.key" @click.stop>
<header>
<div>
<Eyebrow>Feature flag</Eyebrow>
<h2><Mono>{{ flag.key }}</Mono></h2>
</div>
<button class="x" type="button" aria-label="Close" @click="emit('close')">
<UiIcon name="x" :size="13" />
</button>
</header>
<div class="body">
<section>
<label class="label">Description</label>
<input v-model="draft.description" type="text" />
</section>
<section>
<label class="label">State</label>
<div class="seg four">
<button
v-for="s in STATES"
:key="s"
:class="{ on: draft.state === s }"
type="button"
@click="draft.state = s"
>{{ s }}</button>
</div>
</section>
<section v-if="draft.state === 'rollout'">
<label class="label">Rollout percentage</label>
<div class="rollout-row">
<input v-model.number="draft.pct" type="range" min="0" max="100" />
<Mono class="pct">{{ draft.pct }}%</Mono>
</div>
<Mono dim>~{{ rolloutCount }} of {{ totalTenants }} tenants will see this flag enabled</Mono>
</section>
<section v-if="draft.state === 'targeted' || draft.state === 'rollout'">
<label class="label">Plans</label>
<div class="chips">
<button
v-for="p in ALL_PLANS"
:key="p"
:class="['chip', { on: draft.plans.includes(p) }]"
type="button"
@click="togglePlan(p)"
>{{ p }}</button>
</div>
<Mono dim>empty = any plan</Mono>
</section>
<section v-if="draft.state === 'targeted' || draft.state === 'rollout'">
<label class="label">Tenant allowlist</label>
<div v-if="tenants.length" class="chips wrap">
<button
v-for="t in tenants"
:key="t.slug"
:class="['chip', { on: draft.tenantSlugs.includes(t.slug) }]"
type="button"
:title="t.name"
@click="toggleTenant(t.slug)"
>{{ t.slug }}</button>
</div>
<Mono dim>empty = any tenant</Mono>
</section>
<section v-if="draft.state === 'targeted' || draft.state === 'rollout'">
<label class="label">Environments</label>
<div class="chips">
<button
v-for="e in ALL_ENVS"
:key="e"
:class="['chip', { on: draft.environments.includes(e) }]"
type="button"
@click="toggleEnv(e)"
>{{ e }}</button>
</div>
<Mono dim>empty = any environment</Mono>
</section>
<section v-if="dirty">
<label class="label">Change note (optional)</label>
<input v-model="draft.note" type="text" placeholder="why are you changing this?" />
</section>
<section>
<label class="label">Change history</label>
<div class="history">
<div v-for="(h, i) in [...flag.history].reverse()" :key="i" class="hist-row">
<Mono dim>{{ fmtDate(h.at) }}</Mono>
<Mono>{{ h.byEmail || 'system' }}</Mono>
<span>{{ h.action }}</span>
<Mono v-if="h.note" dim class="hist-note"> {{ h.note }}</Mono>
</div>
<Mono v-if="!flag.history.length" dim>no history</Mono>
</div>
</section>
<p v-if="error" class="err">{{ error }}</p>
</div>
<footer>
<UiButton variant="danger" :disabled="busy || flag.state === 'off'" @click="killSwitch">
<template #leading><UiIcon name="x" :size="13" /></template>
Kill-switch
</UiButton>
<div class="spacer" />
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<UiButton variant="primary" :disabled="!dirty || busy" @click="save">
{{ busy ? 'Saving' : 'Save changes' }}
</UiButton>
</footer>
</aside>
</Transition>
</Teleport>
</template>
<style scoped>
.backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); z-index: 190; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(560px, 100vw);
background: var(--elevated);
border-left: 1px solid var(--border);
box-shadow: -16px 0 48px rgba(0, 0, 0, 0.35);
z-index: 200;
display: flex; flex-direction: column;
overflow: hidden;
}
header {
padding: 18px 22px 14px 22px;
border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: flex-start;
}
h2 {
margin: 4px 0 0 0;
font-family: var(--font-display);
font-weight: 600;
font-size: 20px;
}
.x {
width: 28px; height: 28px;
border: 0; border-radius: 6px; background: transparent;
color: var(--text-mute); cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.x:hover { background: var(--surface); color: var(--text); }
.body {
flex: 1; overflow-y: auto;
padding: 20px 22px;
display: flex; flex-direction: column; gap: 18px;
}
section { display: flex; flex-direction: column; gap: 8px; }
.label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
input[type='text'] {
height: 34px;
padding: 0 12px;
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
outline: 0;
}
input[type='text']:focus { border-color: var(--border-hi); }
.seg {
display: grid;
gap: 4px;
padding: 3px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
width: fit-content;
}
.seg.four { grid-template-columns: repeat(4, minmax(72px, 1fr)); }
.seg button {
appearance: none;
border: 0;
background: transparent;
color: var(--text-dim);
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 6px 12px;
border-radius: 5px;
cursor: pointer;
}
.seg button:hover { color: var(--text); }
.seg button.on { background: var(--text); color: var(--bg); }
.rollout-row { display: flex; align-items: center; gap: 14px; }
.rollout-row input { flex: 1; accent-color: var(--accent); }
.pct { font-size: 14px; font-weight: 600; min-width: 50px; text-align: right; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips.wrap { max-height: 140px; overflow-y: auto; padding: 4px 0; }
.chip {
appearance: none;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
padding: 4px 10px;
border-radius: 999px;
cursor: pointer;
}
.chip:hover { color: var(--text); border-color: var(--border-hi); }
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
.history {
display: flex; flex-direction: column; gap: 6px;
border-left: 2px solid var(--border);
padding-left: 12px;
max-height: 200px;
overflow-y: auto;
}
.hist-row {
display: grid;
grid-template-columns: 110px 160px 1fr;
gap: 10px;
align-items: baseline;
font-size: 12px;
}
.hist-note { grid-column: 1 / -1; padding-left: 0; }
.err {
margin: 0;
padding: 10px 12px;
background: rgba(240, 88, 88, 0.08);
border: 1px solid rgba(240, 88, 88, 0.24);
color: var(--bad);
border-radius: 6px;
font-size: 12px;
}
footer {
border-top: 1px solid var(--border);
background: var(--surface);
padding: 12px 18px;
display: flex; gap: 8px; align-items: center;
}
.spacer { flex: 1; }
.drawer-fade-enter-active, .drawer-fade-leave-active { transition: opacity 0.18s ease; }
.drawer-fade-enter-from, .drawer-fade-leave-to { opacity: 0; }
.drawer-slide-enter-active, .drawer-slide-leave-active { transition: transform 0.22s cubic-bezier(0.2, 0.7, 0.2, 1); }
.drawer-slide-enter-from, .drawer-slide-leave-to { transform: translateX(100%); }
</style>