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.
370 lines
10 KiB
Vue
370 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import type { IconName } from './UiIcon.vue'
|
|
import type { Tenant } from '~/types/tenant'
|
|
import type { Partner } from '~/types/partner'
|
|
import type { Flag } from '~/types/flag'
|
|
import { INCIDENT } from '~/data/fixtures'
|
|
|
|
// Each row in the palette. `action` decides what happens on Enter / click.
|
|
// We try `to` first (a navigateTo) since most rows are navigation; `run` is
|
|
// the escape hatch for actions that aren't a route (open modal, toast, etc.).
|
|
interface Row {
|
|
id: string
|
|
groupLabel: string
|
|
title: string
|
|
subtitle?: string
|
|
icon?: IconName
|
|
badge?: string
|
|
to?: string
|
|
run?: () => void
|
|
}
|
|
|
|
const { isOpen, close } = useCommandPalette()
|
|
const { open: openImpersonation } = useImpersonation()
|
|
const { open: openIncident } = useIncidentModal()
|
|
|
|
const query = ref('')
|
|
const cursor = ref(0)
|
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
// Only fetch when the palette has been opened at least once, then refresh on
|
|
// every open so newly-created tenants/partners show up. SSR is disabled (the
|
|
// palette is client-only).
|
|
const { data: tenants, refresh: rT } = useLazyFetch<Tenant[]>('/api/tenants', { default: () => [], server: false })
|
|
const { data: partners, refresh: rP } = useLazyFetch<Partner[]>('/api/partners', { default: () => [], server: false })
|
|
const { data: flags, refresh: rF } = useLazyFetch<Flag[]>('/api/flags', { default: () => [], server: false })
|
|
|
|
const NAV: { id: string; label: string; icon: IconName; to: string }[] = [
|
|
{ id: 'n-overview', label: 'Overview', icon: 'home', to: '/' },
|
|
{ id: 'n-tenants', label: 'Tenants', icon: 'building', to: '/tenants' },
|
|
{ id: 'n-partners', label: 'Partners', icon: 'briefcase', to: '/partners' },
|
|
{ id: 'n-users', label: 'Users (global)', icon: 'users', to: '/users' },
|
|
{ id: 'n-support', label: 'Support', icon: 'help', to: '/support' },
|
|
{ id: 'n-billing', label: 'Platform billing', icon: 'card', to: '/billing' },
|
|
{ id: 'n-reports', label: 'Reports', icon: 'database', to: '/reports' },
|
|
{ id: 'n-infra', label: 'Infrastructure', icon: 'plug', to: '/infrastructure' },
|
|
{ id: 'n-flags', label: 'Feature flags', icon: 'shield', to: '/flags' },
|
|
{ id: 'n-audit', label: 'Audit log', icon: 'file', to: '/audit' },
|
|
{ id: 'n-team', label: 'Operator team', icon: 'users', to: '/operator-team' },
|
|
{ id: 'n-settings', label: 'Platform settings',icon: 'shield', to: '/settings' },
|
|
]
|
|
|
|
function tenantRows(): Row[] {
|
|
return (tenants.value ?? []).map((t) => ({
|
|
id: `t-${t._id}`,
|
|
groupLabel: 'Tenants',
|
|
title: t.name,
|
|
subtitle: `${t.domains?.[0] ?? t.slug} · ${t.plan} · ${t.status}`,
|
|
icon: 'building',
|
|
badge: t.status,
|
|
to: `/tenants/${t.slug}`,
|
|
}))
|
|
}
|
|
|
|
function partnerRows(): Row[] {
|
|
return (partners.value ?? []).map((p) => ({
|
|
id: `p-${p._id}`,
|
|
groupLabel: 'Partners',
|
|
title: p.name,
|
|
subtitle: `${p.domain} · ${p.customers} customers · ${p.marginPct}% margin`,
|
|
icon: 'briefcase',
|
|
badge: p.status,
|
|
to: `/partners/${p.slug}`,
|
|
}))
|
|
}
|
|
|
|
function flagRows(): Row[] {
|
|
return (flags.value ?? []).slice(0, 8).map((f) => ({
|
|
id: `f-${f.key}`,
|
|
groupLabel: 'Feature flags',
|
|
title: f.key,
|
|
subtitle: f.state === 'rollout' ? `rollout · ${f.pct}%` : f.state,
|
|
icon: 'plug',
|
|
badge: f.state,
|
|
to: '/flags',
|
|
}))
|
|
}
|
|
|
|
function navRows(): Row[] {
|
|
return NAV.map((n) => ({
|
|
id: n.id, groupLabel: 'Navigation', title: n.label, subtitle: 'navigate', icon: n.icon, to: n.to,
|
|
}))
|
|
}
|
|
|
|
function actionRows(): Row[] {
|
|
const firstTenant = tenants.value?.[0]
|
|
return [
|
|
{
|
|
id: 'a-impersonate',
|
|
groupLabel: 'Actions',
|
|
title: 'Impersonate user…',
|
|
subtitle: firstTenant ? `enter ${firstTenant.name} as a user` : 'no tenants yet',
|
|
icon: 'key',
|
|
run: () => firstTenant && openImpersonation(firstTenant),
|
|
},
|
|
{
|
|
id: 'a-incident',
|
|
groupLabel: 'Actions',
|
|
title: 'Open active incident',
|
|
subtitle: INCIDENT.title,
|
|
icon: 'bell',
|
|
run: () => openIncident(),
|
|
},
|
|
{
|
|
id: 'a-toggle-theme',
|
|
groupLabel: 'Actions',
|
|
title: 'Toggle theme',
|
|
subtitle: 'dark / light',
|
|
icon: 'shield',
|
|
run: () => {
|
|
const { state, setTheme } = useTweaks()
|
|
setTheme(state.value.theme === 'dark' ? 'light' : 'dark')
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
interface Group { label: string; items: Row[] }
|
|
|
|
const groups = computed<Group[]>(() => {
|
|
const q = query.value.trim().toLowerCase()
|
|
const all: Row[] = [
|
|
...tenantRows(),
|
|
...partnerRows(),
|
|
...flagRows(),
|
|
...navRows(),
|
|
...actionRows(),
|
|
]
|
|
const matches = q
|
|
? all.filter((r) => r.title.toLowerCase().includes(q) || (r.subtitle ?? '').toLowerCase().includes(q))
|
|
: all
|
|
const grouped = new Map<string, Row[]>()
|
|
for (const r of matches) {
|
|
const arr = grouped.get(r.groupLabel) ?? []
|
|
arr.push(r)
|
|
grouped.set(r.groupLabel, arr)
|
|
}
|
|
// Stable group order
|
|
const order = ['Tenants', 'Partners', 'Feature flags', 'Navigation', 'Actions']
|
|
return order
|
|
.filter((g) => grouped.has(g))
|
|
.map((g) => ({ label: g, items: (grouped.get(g) ?? []).slice(0, 8) }))
|
|
})
|
|
|
|
const flat = computed(() => groups.value.flatMap((g) => g.items))
|
|
|
|
watch(isOpen, async (v) => {
|
|
if (!v) return
|
|
query.value = ''
|
|
cursor.value = 0
|
|
await Promise.all([rT(), rP(), rF()])
|
|
await nextTick()
|
|
inputRef.value?.focus()
|
|
})
|
|
|
|
watch(query, () => {
|
|
cursor.value = 0
|
|
})
|
|
|
|
function activate(row?: Row) {
|
|
if (!row) return
|
|
if (row.run) row.run()
|
|
if (row.to) navigateTo(row.to)
|
|
close()
|
|
}
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
close()
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
cursor.value = Math.min(cursor.value + 1, Math.max(0, flat.value.length - 1))
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
cursor.value = Math.max(0, cursor.value - 1)
|
|
}
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
activate(flat.value[cursor.value])
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<div v-if="isOpen" class="palette-backdrop" @click="close">
|
|
<div class="palette" role="dialog" aria-label="Command palette" @click.stop>
|
|
<div class="input-row">
|
|
<UiIcon name="search" :size="14" />
|
|
<input
|
|
ref="inputRef"
|
|
v-model="query"
|
|
type="text"
|
|
placeholder="Search tenants, partners, actions…"
|
|
autocomplete="off"
|
|
@keydown="onKey"
|
|
/>
|
|
<span class="kbd">ESC</span>
|
|
</div>
|
|
|
|
<div class="results">
|
|
<template v-if="groups.length">
|
|
<div v-for="g in groups" :key="g.label" class="group">
|
|
<div class="group-label">{{ g.label }}</div>
|
|
<button
|
|
v-for="item in g.items"
|
|
:key="item.id"
|
|
type="button"
|
|
:class="['row', { active: item.id === flat[cursor]?.id }]"
|
|
@mouseenter="cursor = flat.findIndex((x) => x.id === item.id)"
|
|
@click="activate(item)"
|
|
>
|
|
<span class="icon-tile">
|
|
<UiIcon v-if="item.icon" :name="item.icon" :size="13" />
|
|
</span>
|
|
<span class="row-body">
|
|
<span class="row-title">{{ item.title }}</span>
|
|
<span v-if="item.subtitle" class="row-sub">{{ item.subtitle }}</span>
|
|
</span>
|
|
<Mono v-if="item.badge" dim>{{ item.badge }}</Mono>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<div v-else class="empty">
|
|
<Mono dim>// no matches for "{{ query }}"</Mono>
|
|
</div>
|
|
</div>
|
|
|
|
<footer>
|
|
<Mono dim>{{ flat.length }} {{ flat.length === 1 ? 'result' : 'results' }}</Mono>
|
|
<Mono dim>↑↓ navigate · ↵ open · esc close</Mono>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.palette-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding: 15vh 24px 24px 24px;
|
|
z-index: 200;
|
|
}
|
|
|
|
.palette {
|
|
width: 100%;
|
|
max-width: 640px;
|
|
background: var(--elevated);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: 70vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.input-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 14px 18px;
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text-mute);
|
|
}
|
|
.input-row input {
|
|
flex: 1;
|
|
border: 0;
|
|
outline: 0;
|
|
background: transparent;
|
|
font-family: inherit;
|
|
font-size: 15px;
|
|
color: var(--text);
|
|
}
|
|
.kbd {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
background: var(--surface);
|
|
border-radius: 3px;
|
|
color: var(--text-mute);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.results {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 6px;
|
|
}
|
|
.group { margin-bottom: 4px; }
|
|
.group-label {
|
|
padding: 10px 14px 4px 14px;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
font-weight: 500;
|
|
color: var(--text-mute);
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
width: 100%;
|
|
padding: 8px 14px;
|
|
background: transparent;
|
|
border: 0;
|
|
border-left: 2px solid transparent;
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.row.active {
|
|
background: rgba(212, 255, 58, 0.08);
|
|
border-left-color: var(--accent);
|
|
}
|
|
.icon-tile {
|
|
width: 22px;
|
|
height: 22px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-mute);
|
|
flex-shrink: 0;
|
|
}
|
|
.row.active .icon-tile { color: var(--accent); }
|
|
|
|
.row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
.row-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.row-sub {
|
|
font-size: 11px;
|
|
color: var(--text-mute);
|
|
font-family: var(--font-mono);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.empty { padding: 40px 18px; text-align: center; }
|
|
|
|
footer {
|
|
padding: 8px 14px;
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
</style>
|