868a305539
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.
192 lines
6.5 KiB
Vue
192 lines
6.5 KiB
Vue
<script setup lang="ts">
|
|
import type { Flag, FlagState } from '~/types/flag'
|
|
import type { Tenant } from '~/types/tenant'
|
|
|
|
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: 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>
|
|
<div>
|
|
<PageHeader
|
|
eyebrow="Engineering"
|
|
title="Feature flags"
|
|
:subtitle="`${flags.length} flags. Toggle, target, and roll out features without redeploying.`"
|
|
>
|
|
<template #actions>
|
|
<UiButton variant="primary" @click="showNew = true">
|
|
<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>
|
|
<th class="r"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<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>
|
|
<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.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>
|
|
<Mono>{{ f.pct }}%</Mono>
|
|
</div>
|
|
<Mono v-else dim>—</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">
|
|
// 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>
|
|
|
|
<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);
|
|
}
|
|
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: 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; 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>
|