Skip to content

Commit 6687040

Browse files
authored
Merge pull request #852 from ayinde38/issue6
feat: implement dynamic feature flag system with admin dashboard and evaluate API
2 parents 9df52d6 + 6f55c1d commit 6687040

22 files changed

Lines changed: 3083 additions & 33 deletions

File tree

.github/workflows/load-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ on:
1212
required: false
1313
default: 'http://localhost:3001'
1414
test:
15-
description: 'Test to run (smoke|stress|soak|spike)'
15+
description: 'Test to run (smoke|stress|soak|spike|sequence-collision)'
1616
required: false
1717
default: 'smoke'
1818

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Nightly Tier Upgrade
2+
3+
on:
4+
schedule:
5+
# Run at 02:00 UTC every night
6+
- cron: '0 2 * * *'
7+
workflow_dispatch:
8+
inputs:
9+
dry_run:
10+
description: 'Dry run (no DB writes)'
11+
required: false
12+
default: 'false'
13+
14+
jobs:
15+
tier-upgrade:
16+
name: Upgrade creator verification tiers
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: pnpm/action-setup@v4
23+
with:
24+
version: 10
25+
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: '22'
29+
cache: 'pnpm'
30+
31+
- name: Install dependencies
32+
run: pnpm install --frozen-lockfile
33+
34+
- name: Generate Prisma client
35+
run: pnpm exec prisma generate
36+
37+
- name: Run tier upgrade
38+
env:
39+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
40+
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
41+
run: |
42+
pnpm exec tsx lib/cron/tier-upgrade.ts
43+
44+
- name: Notify on failure
45+
if: failure()
46+
uses: actions/github-script@v7
47+
with:
48+
script: |
49+
github.rest.issues.createComment({
50+
owner: context.repo.owner,
51+
repo: context.repo.repo,
52+
issue_number: 832,
53+
body: '⚠️ Nightly tier upgrade failed. Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.'
54+
})

app/admin/feature-flags/page.tsx

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
'use client';
2+
3+
/**
4+
* Feature Flags Admin Panel — Issue #837
5+
*
6+
* Lists all flags with toggle, rollout-percent slider, and analytics.
7+
* Route: /admin/feature-flags
8+
*/
9+
10+
import { useState, useEffect, useCallback } from 'react';
11+
12+
interface FlagRow {
13+
id: string;
14+
name: string;
15+
enabled: boolean;
16+
rolloutPercent: number;
17+
description: string | null;
18+
_count: { evaluations: number };
19+
updatedAt: string;
20+
}
21+
22+
export default function FeatureFlagsAdminPage() {
23+
const [flags, setFlags] = useState<FlagRow[]>([]);
24+
const [loading, setLoading] = useState(true);
25+
const [saving, setSaving] = useState<string | null>(null);
26+
const [newFlag, setNewFlag] = useState({
27+
name: '',
28+
description: '',
29+
rolloutPercent: 0,
30+
});
31+
32+
const loadFlags = useCallback(async () => {
33+
try {
34+
const res = await fetch('/api/admin/feature-flags');
35+
if (res.ok) setFlags(await res.json());
36+
} finally {
37+
setLoading(false);
38+
}
39+
}, []);
40+
41+
useEffect(() => { loadFlags(); }, [loadFlags]);
42+
43+
async function toggleFlag(name: string, enabled: boolean) {
44+
setSaving(name);
45+
try {
46+
await fetch(`/api/admin/feature-flags/${encodeURIComponent(name)}`, {
47+
method: 'PATCH',
48+
headers: { 'Content-Type': 'application/json' },
49+
body: JSON.stringify({ enabled }),
50+
});
51+
setFlags((prev) =>
52+
prev.map((f) => (f.name === name ? { ...f, enabled } : f)),
53+
);
54+
} finally {
55+
setSaving(null);
56+
}
57+
}
58+
59+
async function updateRollout(name: string, rolloutPercent: number) {
60+
setSaving(name);
61+
try {
62+
await fetch(`/api/admin/feature-flags/${encodeURIComponent(name)}`, {
63+
method: 'PATCH',
64+
headers: { 'Content-Type': 'application/json' },
65+
body: JSON.stringify({ rolloutPercent }),
66+
});
67+
setFlags((prev) =>
68+
prev.map((f) => (f.name === name ? { ...f, rolloutPercent } : f)),
69+
);
70+
} finally {
71+
setSaving(null);
72+
}
73+
}
74+
75+
async function createFlag(e: React.FormEvent) {
76+
e.preventDefault();
77+
setSaving('__new__');
78+
try {
79+
const res = await fetch('/api/admin/feature-flags', {
80+
method: 'POST',
81+
headers: { 'Content-Type': 'application/json' },
82+
body: JSON.stringify({ ...newFlag, enabled: false }),
83+
});
84+
if (res.ok) {
85+
setNewFlag({ name: '', description: '', rolloutPercent: 0 });
86+
loadFlags();
87+
}
88+
} finally {
89+
setSaving(null);
90+
}
91+
}
92+
93+
async function deleteFlag(name: string) {
94+
if (!confirm(`Delete flag "${name}"?`)) return;
95+
setSaving(name);
96+
try {
97+
await fetch(`/api/admin/feature-flags/${encodeURIComponent(name)}`, {
98+
method: 'DELETE',
99+
});
100+
setFlags((prev) => prev.filter((f) => f.name !== name));
101+
} finally {
102+
setSaving(null);
103+
}
104+
}
105+
106+
if (loading) {
107+
return (
108+
<div className="flex items-center justify-center h-64 text-muted-foreground">
109+
Loading feature flags…
110+
</div>
111+
);
112+
}
113+
114+
return (
115+
<div className="max-w-5xl mx-auto p-6 space-y-8">
116+
<div>
117+
<h1 className="text-2xl font-bold tracking-tight">Feature Flags</h1>
118+
<p className="text-sm text-muted-foreground mt-1">
119+
Changes propagate to users within 60 seconds via Redis cache TTL.
120+
</p>
121+
</div>
122+
123+
{/* ── Flag table ──────────────────────────────────────────────────── */}
124+
<div className="rounded-xl border border-border overflow-hidden">
125+
<table className="w-full text-sm">
126+
<thead className="bg-muted/40">
127+
<tr>
128+
<th className="text-left px-4 py-3 font-medium">Flag</th>
129+
<th className="text-left px-4 py-3 font-medium">Enabled</th>
130+
<th className="text-left px-4 py-3 font-medium">Rollout %</th>
131+
<th className="text-left px-4 py-3 font-medium">Evaluations</th>
132+
<th className="text-left px-4 py-3 font-medium">Updated</th>
133+
<th className="px-4 py-3" />
134+
</tr>
135+
</thead>
136+
<tbody className="divide-y divide-border">
137+
{flags.map((flag) => (
138+
<tr key={flag.id} className="hover:bg-muted/20 transition-colors">
139+
{/* Name + description */}
140+
<td className="px-4 py-3">
141+
<span className="font-mono font-semibold text-foreground">
142+
{flag.name}
143+
</span>
144+
{flag.description && (
145+
<p className="text-xs text-muted-foreground mt-0.5">
146+
{flag.description}
147+
</p>
148+
)}
149+
</td>
150+
151+
{/* Toggle */}
152+
<td className="px-4 py-3">
153+
<button
154+
id={`flag-toggle-${flag.name}`}
155+
disabled={saving === flag.name}
156+
onClick={() => toggleFlag(flag.name, !flag.enabled)}
157+
aria-label={`Toggle ${flag.name}`}
158+
className={[
159+
'relative w-10 h-6 rounded-full transition-colors focus-visible:ring-2',
160+
flag.enabled ? 'bg-emerald-500' : 'bg-border',
161+
saving === flag.name ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
162+
].join(' ')}
163+
>
164+
<span
165+
className={[
166+
'absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
167+
flag.enabled ? 'translate-x-4' : 'translate-x-0',
168+
].join(' ')}
169+
/>
170+
</button>
171+
</td>
172+
173+
{/* Rollout slider */}
174+
<td className="px-4 py-3">
175+
<div className="flex items-center gap-2">
176+
<input
177+
id={`flag-rollout-${flag.name}`}
178+
type="range"
179+
min={0}
180+
max={100}
181+
step={5}
182+
value={flag.rolloutPercent}
183+
disabled={saving === flag.name}
184+
onChange={(e) => {
185+
const v = Number(e.target.value);
186+
setFlags((prev) =>
187+
prev.map((f) =>
188+
f.name === flag.name
189+
? { ...f, rolloutPercent: v }
190+
: f,
191+
),
192+
);
193+
}}
194+
onMouseUp={(e) =>
195+
updateRollout(
196+
flag.name,
197+
Number((e.target as HTMLInputElement).value),
198+
)
199+
}
200+
onTouchEnd={(e) =>
201+
updateRollout(
202+
flag.name,
203+
Number((e.target as HTMLInputElement).value),
204+
)
205+
}
206+
className="w-24 accent-emerald-500"
207+
/>
208+
<span className="w-8 text-right tabular-nums">
209+
{flag.rolloutPercent}%
210+
</span>
211+
</div>
212+
</td>
213+
214+
{/* Evaluations count */}
215+
<td className="px-4 py-3 text-muted-foreground tabular-nums">
216+
{flag._count.evaluations.toLocaleString()}
217+
</td>
218+
219+
{/* Updated at */}
220+
<td className="px-4 py-3 text-muted-foreground text-xs">
221+
{new Date(flag.updatedAt).toLocaleString()}
222+
</td>
223+
224+
{/* Delete */}
225+
<td className="px-4 py-3">
226+
<button
227+
id={`flag-delete-${flag.name}`}
228+
onClick={() => deleteFlag(flag.name)}
229+
disabled={saving === flag.name}
230+
className="text-destructive hover:text-destructive/80 text-xs disabled:opacity-40"
231+
>
232+
Delete
233+
</button>
234+
</td>
235+
</tr>
236+
))}
237+
</tbody>
238+
</table>
239+
</div>
240+
241+
{/* ── Create new flag ──────────────────────────────────────────────── */}
242+
<div className="rounded-xl border border-border p-6 space-y-4">
243+
<h2 className="text-lg font-semibold">Create Flag</h2>
244+
<form onSubmit={createFlag} className="grid grid-cols-1 sm:grid-cols-3 gap-4">
245+
<div className="flex flex-col gap-1">
246+
<label htmlFor="new-flag-name" className="text-sm font-medium">
247+
Name
248+
</label>
249+
<input
250+
id="new-flag-name"
251+
type="text"
252+
placeholder="live_streaming"
253+
value={newFlag.name}
254+
onChange={(e) => setNewFlag((p) => ({ ...p, name: e.target.value }))}
255+
required
256+
className="rounded-lg border border-border bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-emerald-500 outline-none"
257+
/>
258+
</div>
259+
260+
<div className="flex flex-col gap-1">
261+
<label htmlFor="new-flag-desc" className="text-sm font-medium">
262+
Description
263+
</label>
264+
<input
265+
id="new-flag-desc"
266+
type="text"
267+
placeholder="Short description"
268+
value={newFlag.description}
269+
onChange={(e) => setNewFlag((p) => ({ ...p, description: e.target.value }))}
270+
className="rounded-lg border border-border bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-emerald-500 outline-none"
271+
/>
272+
</div>
273+
274+
<div className="flex flex-col gap-1">
275+
<label htmlFor="new-flag-rollout" className="text-sm font-medium">
276+
Rollout %
277+
</label>
278+
<input
279+
id="new-flag-rollout"
280+
type="number"
281+
min={0}
282+
max={100}
283+
value={newFlag.rolloutPercent}
284+
onChange={(e) =>
285+
setNewFlag((p) => ({
286+
...p,
287+
rolloutPercent: Number(e.target.value),
288+
}))
289+
}
290+
className="rounded-lg border border-border bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-emerald-500 outline-none"
291+
/>
292+
</div>
293+
294+
<div className="sm:col-span-3 flex justify-end">
295+
<button
296+
id="create-flag-submit"
297+
type="submit"
298+
disabled={saving === '__new__' || !newFlag.name}
299+
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white px-4 py-2 text-sm font-medium disabled:opacity-50 transition-colors"
300+
>
301+
{saving === '__new__' ? 'Creating…' : 'Create Flag'}
302+
</button>
303+
</div>
304+
</form>
305+
</div>
306+
</div>
307+
);
308+
}

0 commit comments

Comments
 (0)