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.
433 lines
13 KiB
Vue
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>
|