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.
This commit is contained in:
@@ -1,18 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { FLAGS, type FeatureFlag } from '~/data/fixtures'
|
||||
import type { Flag, FlagState } from '~/types/flag'
|
||||
import type { Tenant } from '~/types/tenant'
|
||||
|
||||
function stateTone(f: FeatureFlag): 'ok' | 'neutral' | 'warn' | 'info' {
|
||||
switch (f.state) {
|
||||
const { data: flags, refresh } = await useFetch<Flag[]>('/api/flags', {
|
||||
default: () => [],
|
||||
})
|
||||
// Tenants are needed by FlagDetail for the allowlist picker + the rollout
|
||||
// preview ("~N of M tenants will see this"). Lazy because we only need them
|
||||
// when the side panel opens.
|
||||
const { data: tenants } = useLazyFetch<Tenant[]>('/api/tenants', {
|
||||
default: () => [],
|
||||
server: false,
|
||||
})
|
||||
|
||||
const open = ref<Flag | null>(null)
|
||||
const showNew = ref(false)
|
||||
|
||||
function stateTone(state: FlagState): 'ok' | 'neutral' | 'warn' | 'info' {
|
||||
switch (state) {
|
||||
case 'on': return 'ok'
|
||||
case 'off': return 'neutral'
|
||||
case 'rollout': return 'warn'
|
||||
case 'targeted': return 'info'
|
||||
}
|
||||
}
|
||||
function stateLabel(f: FeatureFlag) {
|
||||
function stateLabel(f: Flag) {
|
||||
if (f.state === 'rollout') return `${f.pct}% rollout`
|
||||
return f.state
|
||||
}
|
||||
|
||||
async function onSaved(updated: Flag) {
|
||||
// Replace the row in-place so the list reflects the new state without a
|
||||
// full refetch — but also refresh in the background to pick up any
|
||||
// server-side derived fields (history entries, updatedAt).
|
||||
flags.value = (flags.value ?? []).map((f) => (f.key === updated.key ? updated : f))
|
||||
open.value = updated
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function onCreated(created: Flag) {
|
||||
flags.value = [...(flags.value ?? []), created].sort((a, b) => a.key.localeCompare(b.key))
|
||||
showNew.value = false
|
||||
open.value = created
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
return new Date(s).toLocaleDateString('da-DK', { day: '2-digit', month: 'short' })
|
||||
}
|
||||
|
||||
function describeScope(f: Flag): string {
|
||||
if (f.state === 'on') return 'all'
|
||||
if (f.state === 'off') return '—'
|
||||
const parts: string[] = []
|
||||
if (f.scope.plans.length) parts.push(`plan: ${f.scope.plans.join(', ')}`)
|
||||
if (f.scope.tenantSlugs.length) parts.push(`${f.scope.tenantSlugs.length} tenant${f.scope.tenantSlugs.length === 1 ? '' : 's'}`)
|
||||
if (f.scope.partnerSlugs.length) parts.push(`${f.scope.partnerSlugs.length} partner${f.scope.partnerSlugs.length === 1 ? '' : 's'}`)
|
||||
if (f.scope.environments.length) parts.push(`env: ${f.scope.environments.join('/')}`)
|
||||
return parts.length ? parts.join(' · ') : 'no targeting'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,10 +65,10 @@ function stateLabel(f: FeatureFlag) {
|
||||
<PageHeader
|
||||
eyebrow="Engineering"
|
||||
title="Feature flags"
|
||||
subtitle="Toggle, target, and roll out platform features. Every change is logged."
|
||||
:subtitle="`${flags.length} flags. Toggle, target, and roll out features without redeploying.`"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="primary" disabled>
|
||||
<UiButton variant="primary" @click="showNew = true">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
New flag
|
||||
</UiButton>
|
||||
@@ -40,19 +85,23 @@ function stateLabel(f: FeatureFlag) {
|
||||
<th>Rollout</th>
|
||||
<th>Scope</th>
|
||||
<th>Last modified</th>
|
||||
<th class="r"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in FLAGS" :key="f.key">
|
||||
<tr v-for="f in flags" :key="f.key" @click="open = f">
|
||||
<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>
|
||||
<Mono class="key-name">{{ f.key }}</Mono>
|
||||
<div v-if="f.description" class="key-desc">{{ f.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge :tone="stateTone(f)" dot>{{ stateLabel(f) }}</Badge></td>
|
||||
<td><Badge :tone="stateTone(f.state)" 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>
|
||||
@@ -60,15 +109,39 @@ function stateLabel(f: FeatureFlag) {
|
||||
</div>
|
||||
<Mono v-else dim>—</Mono>
|
||||
</td>
|
||||
<td><Mono dim>{{ f.scope }}</Mono></td>
|
||||
<td><Mono dim>{{ f.modified }}</Mono></td>
|
||||
<td>
|
||||
<Mono dim>{{ describeScope(f) }}</Mono>
|
||||
</td>
|
||||
<td>
|
||||
<Mono dim>{{ fmtDate(f.updatedAt) }}</Mono>
|
||||
</td>
|
||||
<td class="r">
|
||||
<UiIcon name="chevRight" :size="12" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!flags.length">
|
||||
<td colspan="6" class="empty">
|
||||
<Mono dim>// no flags yet</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>
|
||||
<Mono dim class="note">
|
||||
// backed by /api/flags · seed contains 10 flags mapping to real Dezky
|
||||
concerns. Consumers (portal, platform-api) call POST /api/flags/evaluate
|
||||
with a tenant slug to get current boolean values.
|
||||
</Mono>
|
||||
</div>
|
||||
|
||||
<FlagDetail
|
||||
:flag="open"
|
||||
:tenants="tenants ?? []"
|
||||
@close="open = null"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
<NewFlagModal :open="showNew" @close="showNew = false" @created="onCreated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -87,26 +160,32 @@ th {
|
||||
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; }
|
||||
th.r, td.r { text-align: right; }
|
||||
td { padding: 12px 20px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; color: var(--text-mute); }
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr:hover { background: var(--surface); }
|
||||
|
||||
.key { display: flex; align-items: center; gap: 10px; }
|
||||
.key-tile {
|
||||
width: 22px; height: 22px;
|
||||
width: 24px; height: 24px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
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; }
|
||||
.key-name { font-weight: 600; color: var(--text); display: block; }
|
||||
.key-desc { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
|
||||
|
||||
.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); }
|
||||
|
||||
.empty { padding: 32px 20px; text-align: center; }
|
||||
.note { display: block; padding: 4px 4px 0 4px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user