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.
177 lines
4.7 KiB
Vue
177 lines
4.7 KiB
Vue
<script setup lang="ts">
|
||
// "New flag" modal — opens from the page header. Only collects the absolute
|
||
// minimum (key + optional description); state/rollout/targeting are tuned in
|
||
// the FlagDetail side panel after creation, which is the same surface used
|
||
// for every subsequent edit.
|
||
|
||
import type { Flag } from '~/types/flag'
|
||
|
||
const props = defineProps<{ open: boolean }>()
|
||
const emit = defineEmits<{ close: []; created: [Flag] }>()
|
||
|
||
const key = ref('')
|
||
const description = ref('')
|
||
const error = ref<string | null>(null)
|
||
const busy = ref(false)
|
||
|
||
watch(
|
||
() => props.open,
|
||
(v) => {
|
||
if (v) {
|
||
key.value = ''
|
||
description.value = ''
|
||
error.value = null
|
||
}
|
||
},
|
||
)
|
||
|
||
onMounted(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape' && props.open) emit('close')
|
||
}
|
||
document.addEventListener('keydown', onKey)
|
||
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||
})
|
||
|
||
const valid = computed(() => /^[a-z][a-z0-9_]{1,62}[a-z0-9]$/.test(key.value.trim()))
|
||
|
||
async function submit() {
|
||
if (!valid.value) return
|
||
busy.value = true
|
||
error.value = null
|
||
try {
|
||
const created = await $fetch<Flag>('/api/flags', {
|
||
method: 'POST',
|
||
body: { key: key.value.trim(), description: description.value.trim() },
|
||
})
|
||
emit('created', created)
|
||
} catch (e) {
|
||
const err = e as { data?: { data?: { message?: string }; message?: string } }
|
||
error.value = err.data?.data?.message || err.data?.message || 'Create failed'
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport to="body">
|
||
<div v-if="open" class="backdrop" @click="emit('close')">
|
||
<div class="modal" role="dialog" aria-label="New feature flag" @click.stop>
|
||
<header>
|
||
<div>
|
||
<Eyebrow>Engineering</Eyebrow>
|
||
<h2>New feature flag</h2>
|
||
</div>
|
||
<button class="x" type="button" aria-label="Close" @click="emit('close')">
|
||
<UiIcon name="x" :size="12" />
|
||
</button>
|
||
</header>
|
||
|
||
<div class="body">
|
||
<section>
|
||
<label class="label">Key</label>
|
||
<input v-model="key" type="text" placeholder="e.g. new_inbox_view" />
|
||
<Mono dim>lowercase snake_case · starts with a letter · 3–64 chars</Mono>
|
||
</section>
|
||
<section>
|
||
<label class="label">Description (optional)</label>
|
||
<input v-model="description" type="text" placeholder="what does this flag gate?" />
|
||
</section>
|
||
|
||
<p v-if="error" class="err">{{ error }}</p>
|
||
</div>
|
||
|
||
<footer>
|
||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||
<UiButton variant="primary" :disabled="!valid || busy" @click="submit">
|
||
{{ busy ? 'Creating…' : 'Create flag' }}
|
||
</UiButton>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.backdrop {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
display: flex; align-items: center; justify-content: center;
|
||
padding: 24px;
|
||
z-index: 180;
|
||
}
|
||
.modal {
|
||
width: 100%; max-width: 460px;
|
||
background: var(--elevated);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
||
display: flex; flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
header {
|
||
padding: 16px 20px;
|
||
display: flex; justify-content: space-between; align-items: flex-start;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
h2 {
|
||
margin: 4px 0 0 0;
|
||
font-family: var(--font-display);
|
||
font-weight: 600;
|
||
font-size: 18px;
|
||
}
|
||
.x {
|
||
width: 26px; height: 26px;
|
||
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 {
|
||
padding: 16px 20px;
|
||
display: flex; flex-direction: column; gap: 14px;
|
||
}
|
||
section { display: flex; flex-direction: column; gap: 6px; }
|
||
.label {
|
||
font-family: var(--font-mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: var(--text-mute);
|
||
font-weight: 500;
|
||
}
|
||
input {
|
||
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:focus { border-color: var(--border-hi); }
|
||
|
||
.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 {
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
border-top: 1px solid var(--border);
|
||
background: var(--surface);
|
||
}
|
||
</style>
|