Skip to content

Commit 5d6518b

Browse files
committed
feat: add global feature flags admin tab with shared flag controls
1 parent 7aa4464 commit 5d6518b

4 files changed

Lines changed: 299 additions & 78 deletions

File tree

apps/webapp/app/components/admin/FeatureFlagsDialog.tsx

Lines changed: 1 addition & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,10 @@ import {
88
DialogFooter,
99
} from "~/components/primitives/Dialog";
1010
import { Button } from "~/components/primitives/Buttons";
11-
import { Switch } from "~/components/primitives/Switch";
12-
import { Select, SelectItem } from "~/components/primitives/Select";
13-
import { Input } from "~/components/primitives/Input";
1411
import { Callout } from "~/components/primitives/Callout";
1512
import { cn } from "~/utils/cn";
1613
import type { FlagControlType } from "~/v3/featureFlags.server";
17-
18-
const UNSET_VALUE = "__unset__";
14+
import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "./FlagControls";
1915

2016
const HIDDEN_FLAGS = ["defaultWorkerInstanceGroupId"];
2117

@@ -238,76 +234,3 @@ export function FeatureFlagsDialog({
238234
);
239235
}
240236

241-
// --- Sub-components ---
242-
243-
function BooleanControl({
244-
value,
245-
onChange,
246-
dimmed,
247-
}: {
248-
value: boolean | undefined;
249-
onChange: (val: boolean) => void;
250-
dimmed: boolean;
251-
}) {
252-
return (
253-
<Switch
254-
variant="small"
255-
checked={value ?? false}
256-
onCheckedChange={onChange}
257-
className={cn(dimmed && "opacity-50")}
258-
/>
259-
);
260-
}
261-
262-
function EnumControl({
263-
value,
264-
options,
265-
onChange,
266-
dimmed,
267-
}: {
268-
value: string | undefined;
269-
options: string[];
270-
onChange: (val: string) => void;
271-
dimmed: boolean;
272-
}) {
273-
const items = [UNSET_VALUE, ...options];
274-
275-
return (
276-
<Select
277-
variant="tertiary/small"
278-
value={value ?? UNSET_VALUE}
279-
setValue={onChange}
280-
items={items}
281-
text={(val) => (val === UNSET_VALUE ? "unset" : val)}
282-
className={cn(dimmed && "opacity-50")}
283-
>
284-
{(items) =>
285-
items.map((item) => (
286-
<SelectItem key={item} value={item}>
287-
{item === UNSET_VALUE ? "unset" : item}
288-
</SelectItem>
289-
))
290-
}
291-
</Select>
292-
);
293-
}
294-
295-
function StringControl({
296-
value,
297-
onChange,
298-
dimmed,
299-
}: {
300-
value: string;
301-
onChange: (val: string) => void;
302-
dimmed: boolean;
303-
}) {
304-
return (
305-
<Input
306-
variant="small"
307-
value={value}
308-
onChange={(e) => onChange(e.target.value)}
309-
placeholder="unset"
310-
className={cn("w-40", dimmed && "opacity-50")}
311-
/>
312-
);
313-
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Switch } from "~/components/primitives/Switch";
2+
import { Select, SelectItem } from "~/components/primitives/Select";
3+
import { Input } from "~/components/primitives/Input";
4+
import { cn } from "~/utils/cn";
5+
6+
export const UNSET_VALUE = "__unset__";
7+
8+
export function BooleanControl({
9+
value,
10+
onChange,
11+
dimmed,
12+
}: {
13+
value: boolean | undefined;
14+
onChange: (val: boolean) => void;
15+
dimmed: boolean;
16+
}) {
17+
return (
18+
<Switch
19+
variant="small"
20+
checked={value ?? false}
21+
onCheckedChange={onChange}
22+
className={cn(dimmed && "opacity-50")}
23+
/>
24+
);
25+
}
26+
27+
export function EnumControl({
28+
value,
29+
options,
30+
onChange,
31+
dimmed,
32+
}: {
33+
value: string | undefined;
34+
options: string[];
35+
onChange: (val: string) => void;
36+
dimmed: boolean;
37+
}) {
38+
const items = [UNSET_VALUE, ...options];
39+
40+
return (
41+
<Select
42+
variant="tertiary/small"
43+
value={value ?? UNSET_VALUE}
44+
setValue={onChange}
45+
items={items}
46+
text={(val) => (val === UNSET_VALUE ? "unset" : val)}
47+
className={cn(dimmed && "opacity-50")}
48+
>
49+
{(items) =>
50+
items.map((item) => (
51+
<SelectItem key={item} value={item}>
52+
{item === UNSET_VALUE ? "unset" : item}
53+
</SelectItem>
54+
))
55+
}
56+
</Select>
57+
);
58+
}
59+
60+
export function StringControl({
61+
value,
62+
onChange,
63+
dimmed,
64+
}: {
65+
value: string;
66+
onChange: (val: string) => void;
67+
dimmed: boolean;
68+
}) {
69+
return (
70+
<Input
71+
variant="small"
72+
value={value}
73+
onChange={(e) => onChange(e.target.value)}
74+
placeholder="unset"
75+
className={cn("w-40", dimmed && "opacity-50")}
76+
/>
77+
);
78+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { useFetcher } from "@remix-run/react";
2+
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
3+
import { useEffect, useState } from "react";
4+
import { json } from "@remix-run/server-runtime";
5+
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
6+
import { prisma } from "~/db.server";
7+
import { requireUser } from "~/services/session.server";
8+
import {
9+
flags as getGlobalFlags,
10+
getAllFlagControlTypes,
11+
validatePartialFeatureFlags,
12+
} from "~/v3/featureFlags.server";
13+
import type { FlagControlType } from "~/v3/featureFlags.server";
14+
import { Button } from "~/components/primitives/Buttons";
15+
import { Callout } from "~/components/primitives/Callout";
16+
import { cn } from "~/utils/cn";
17+
import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "~/components/admin/FlagControls";
18+
19+
export const loader = async ({ request }: LoaderFunctionArgs) => {
20+
const user = await requireUser(request);
21+
if (!user.admin) {
22+
return redirect("/");
23+
}
24+
25+
const globalFlags = await getGlobalFlags();
26+
const controlTypes = getAllFlagControlTypes();
27+
28+
return typedjson({ globalFlags, controlTypes });
29+
};
30+
31+
export const action = async ({ request }: ActionFunctionArgs) => {
32+
const user = await requireUser(request);
33+
if (!user.admin) {
34+
throw new Response("Unauthorized", { status: 403 });
35+
}
36+
37+
const body = await request.json();
38+
const { flags: newFlags } = body as { flags: Record<string, unknown> };
39+
40+
const controlTypes = getAllFlagControlTypes();
41+
const catalogKeys = Object.keys(controlTypes);
42+
43+
// For each catalog key: if value is present in newFlags, upsert it. If absent, delete the row.
44+
for (const key of catalogKeys) {
45+
if (key in newFlags) {
46+
const value = newFlags[key];
47+
// Validate the value against its schema
48+
const partial = { [key]: value };
49+
const result = validatePartialFeatureFlags(partial);
50+
if (result.success) {
51+
await prisma.featureFlag.upsert({
52+
where: { key },
53+
create: { key, value: value as any },
54+
update: { value: value as any },
55+
});
56+
}
57+
} else {
58+
// Unset - delete the row if it exists
59+
await prisma.featureFlag.deleteMany({ where: { key } });
60+
}
61+
}
62+
63+
return json({ success: true });
64+
};
65+
66+
export default function AdminFeatureFlagsRoute() {
67+
const { globalFlags, controlTypes } = useTypedLoaderData<typeof loader>();
68+
const saveFetcher = useFetcher<{ success?: boolean; error?: string }>();
69+
70+
const [values, setValues] = useState<Record<string, unknown>>({});
71+
const [initialValues, setInitialValues] = useState<Record<string, unknown>>({});
72+
const [saveError, setSaveError] = useState<string | null>(null);
73+
74+
// Sync loader data into local state
75+
useEffect(() => {
76+
const loaded = (globalFlags ?? {}) as Record<string, unknown>;
77+
setValues({ ...loaded });
78+
setInitialValues({ ...loaded });
79+
}, [globalFlags]);
80+
81+
useEffect(() => {
82+
if (saveFetcher.data?.success) {
83+
setSaveError(null);
84+
// Update initial to match saved state
85+
setInitialValues({ ...values });
86+
} else if (saveFetcher.data?.error) {
87+
setSaveError(saveFetcher.data.error);
88+
}
89+
}, [saveFetcher.data]);
90+
91+
const isDirty = JSON.stringify(values) !== JSON.stringify(initialValues);
92+
const isSaving = saveFetcher.state === "submitting";
93+
94+
const setFlagValue = (key: string, value: unknown) => {
95+
setValues((prev) => ({ ...prev, [key]: value }));
96+
};
97+
98+
const unsetFlag = (key: string) => {
99+
setValues((prev) => {
100+
const next = { ...prev };
101+
delete next[key];
102+
return next;
103+
});
104+
};
105+
106+
const handleSave = () => {
107+
saveFetcher.submit(JSON.stringify({ flags: values }), {
108+
method: "POST",
109+
encType: "application/json",
110+
});
111+
};
112+
113+
const sortedFlagKeys = Object.keys(controlTypes as Record<string, FlagControlType>).sort();
114+
115+
return (
116+
<main className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last">
117+
<div className="max-w-2xl space-y-4">
118+
<p className="text-sm text-text-dimmed">
119+
Global defaults for all organizations. Org-level overrides take precedence.
120+
</p>
121+
122+
<div className="flex flex-col gap-1.5">
123+
{sortedFlagKeys.map((key) => {
124+
const control = (controlTypes as Record<string, FlagControlType>)[key];
125+
const isSet = key in values;
126+
127+
return (
128+
<div
129+
key={key}
130+
className={cn(
131+
"flex items-center justify-between rounded-md border px-3 py-2.5",
132+
isSet
133+
? "border-indigo-500/20 bg-indigo-500/5"
134+
: "border-transparent bg-charcoal-750"
135+
)}
136+
>
137+
<div className="min-w-0 flex-1">
138+
<div
139+
className={cn(
140+
"truncate text-sm",
141+
isSet ? "text-text-bright" : "text-text-dimmed"
142+
)}
143+
>
144+
{key}
145+
</div>
146+
<div className="text-xs text-charcoal-400">
147+
{isSet ? `value: ${String(values[key])}` : "not set"}
148+
</div>
149+
</div>
150+
151+
<div className="flex items-center gap-2">
152+
<Button
153+
variant="minimal/small"
154+
onClick={() => unsetFlag(key)}
155+
className={cn(!isSet && "invisible")}
156+
>
157+
unset
158+
</Button>
159+
160+
{control.type === "boolean" && (
161+
<BooleanControl
162+
value={isSet ? (values[key] as boolean) : undefined}
163+
onChange={(val) => setFlagValue(key, val)}
164+
dimmed={!isSet}
165+
/>
166+
)}
167+
168+
{control.type === "enum" && (
169+
<EnumControl
170+
value={isSet ? (values[key] as string) : undefined}
171+
options={control.options}
172+
onChange={(val) => {
173+
if (val === UNSET_VALUE) {
174+
unsetFlag(key);
175+
} else {
176+
setFlagValue(key, val);
177+
}
178+
}}
179+
dimmed={!isSet}
180+
/>
181+
)}
182+
183+
{control.type === "string" && (
184+
<StringControl
185+
value={isSet ? (values[key] as string) : ""}
186+
onChange={(val) => {
187+
if (val === "") {
188+
unsetFlag(key);
189+
} else {
190+
setFlagValue(key, val);
191+
}
192+
}}
193+
dimmed={!isSet}
194+
/>
195+
)}
196+
</div>
197+
</div>
198+
);
199+
})}
200+
</div>
201+
202+
{saveError && <Callout variant="error">{saveError}</Callout>}
203+
204+
<div className="flex justify-end gap-2">
205+
<Button
206+
variant="primary/small"
207+
onClick={handleSave}
208+
disabled={!isDirty || isSaving}
209+
>
210+
{isSaving ? "Saving..." : "Save Changes"}
211+
</Button>
212+
</div>
213+
</div>
214+
</main>
215+
);
216+
}

apps/webapp/app/routes/admin.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export default function Page() {
3636
label: "LLM Models",
3737
to: "/admin/llm-models",
3838
},
39+
{
40+
label: "Feature Flags",
41+
to: "/admin/feature-flags",
42+
},
3943
{
4044
label: "Notifications",
4145
to: "/admin/notifications",

0 commit comments

Comments
 (0)