Skip to content

Commit 5447cf4

Browse files
authored
feat(webapp): admin UI for feature flag overrides (#3291)
Adds a dialog to the admin orgs page for viewing and editing per-org feature flag overrides. Flags are introspected from the catalog so the UI stays in sync with available flags automatically. Also adds a new tab for global flags. Refactors featureFlags.server.ts to split catalog definition (shared) from server-only runtime (flags(), makeSetMultipleFlags). The shared module exports flag metadata and validation so both the UI and API routes can use it without pulling in server dependencies.
1 parent 7210bde commit 5447cf4

26 files changed

+1301
-86
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Add admin UI for viewing and editing feature flags (org-level overrides and global defaults).

apps/webapp/CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,12 @@ The `app/v3/` directory name is misleading - most code is actively used by V2. O
9898
- `app/v3/sharedSocketConnection.ts`
9999

100100
Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths.
101+
102+
## Prisma Query Patterns
103+
104+
- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues.
105+
106+
## React Patterns
107+
108+
- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations.
109+
- Use named constants for sentinel/placeholder values (e.g. `const UNSET_VALUE = "__unset__"`) instead of raw string literals scattered across comparisons.
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { useFetcher } from "@remix-run/react";
2+
import { useEffect, useState } from "react";
3+
import stableStringify from "json-stable-stringify";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogHeader,
8+
DialogDescription,
9+
DialogFooter,
10+
} from "~/components/primitives/Dialog";
11+
import { Button } from "~/components/primitives/Buttons";
12+
import { Callout } from "~/components/primitives/Callout";
13+
import { LockClosedIcon } from "@heroicons/react/20/solid";
14+
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
15+
import { cn } from "~/utils/cn";
16+
import { FEATURE_FLAG, ORG_LOCKED_FLAGS, type FlagControlType } from "~/v3/featureFlags";
17+
import {
18+
UNSET_VALUE,
19+
BooleanControl,
20+
EnumControl,
21+
StringControl,
22+
WorkerGroupControl,
23+
type WorkerGroup,
24+
} from "./FlagControls";
25+
26+
type LoaderData = {
27+
org: { id: string; title: string; slug: string };
28+
orgFlags: Record<string, unknown>;
29+
globalFlags: Record<string, unknown>;
30+
controlTypes: Record<string, FlagControlType>;
31+
workerGroupName?: string;
32+
workerGroups?: WorkerGroup[];
33+
isManagedCloud?: boolean;
34+
};
35+
36+
type ActionData = {
37+
success?: boolean;
38+
error?: string;
39+
};
40+
41+
type FeatureFlagsDialogProps = {
42+
orgId: string | null;
43+
orgTitle: string;
44+
open: boolean;
45+
onOpenChange: (open: boolean) => void;
46+
};
47+
48+
export function FeatureFlagsDialog({
49+
orgId,
50+
orgTitle,
51+
open,
52+
onOpenChange,
53+
}: FeatureFlagsDialogProps) {
54+
const loadFetcher = useFetcher<LoaderData>();
55+
const saveFetcher = useFetcher<ActionData>();
56+
57+
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
58+
const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({});
59+
const [saveError, setSaveError] = useState<string | null>(null);
60+
const [unlocked, setUnlocked] = useState(false);
61+
62+
const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key);
63+
64+
useEffect(() => {
65+
if (open && orgId) {
66+
setSaveError(null);
67+
setOverrides({});
68+
setInitialOverrides({});
69+
loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`);
70+
}
71+
}, [open, orgId]);
72+
73+
useEffect(() => {
74+
if (loadFetcher.data) {
75+
const loaded = loadFetcher.data.orgFlags ?? {};
76+
setOverrides({ ...loaded });
77+
setInitialOverrides({ ...loaded });
78+
}
79+
}, [loadFetcher.data]);
80+
81+
useEffect(() => {
82+
if (saveFetcher.data?.success) {
83+
onOpenChange(false);
84+
} else if (saveFetcher.data?.error) {
85+
setSaveError(saveFetcher.data.error);
86+
}
87+
}, [saveFetcher.data]);
88+
89+
const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides);
90+
91+
const setFlagValue = (key: string, value: unknown) => {
92+
setOverrides((prev) => ({ ...prev, [key]: value }));
93+
};
94+
95+
const unsetFlag = (key: string) => {
96+
setOverrides((prev) => {
97+
const next = { ...prev };
98+
delete next[key];
99+
return next;
100+
});
101+
};
102+
103+
const handleSave = () => {
104+
if (!orgId) return;
105+
const body = Object.keys(overrides).length === 0 ? null : overrides;
106+
saveFetcher.submit(JSON.stringify(body), {
107+
method: "POST",
108+
action: `/admin/api/v2/orgs/${orgId}/feature-flags`,
109+
encType: "application/json",
110+
});
111+
};
112+
113+
const data = loadFetcher.data;
114+
const isLoading = loadFetcher.state === "loading";
115+
const isSaving = saveFetcher.state === "submitting";
116+
117+
const jsonPreview =
118+
Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2);
119+
120+
const sortedFlagKeys = data ? Object.keys(data.controlTypes).sort() : [];
121+
122+
return (
123+
<Dialog open={open} onOpenChange={onOpenChange}>
124+
<DialogContent className="sm:max-w-lg">
125+
<DialogHeader>Feature flags - {orgTitle}</DialogHeader>
126+
<DialogDescription>
127+
Org-level overrides. Unset flags inherit from global defaults.
128+
</DialogDescription>
129+
130+
{data && (
131+
<div className={data.isManagedCloud ? "cursor-not-allowed" : undefined}>
132+
<CheckboxWithLabel
133+
variant="simple/small"
134+
label={
135+
data.isManagedCloud
136+
? "Unlock read-only flags (only in unmanaged cloud)"
137+
: "Unlock read-only flags"
138+
}
139+
defaultChecked={unlocked}
140+
onChange={setUnlocked}
141+
disabled={data.isManagedCloud}
142+
className={data.isManagedCloud ? "pointer-events-none" : undefined}
143+
/>
144+
</div>
145+
)}
146+
147+
<div className="max-h-[60vh] overflow-y-auto">
148+
{isLoading ? (
149+
<div className="py-8 text-center text-sm text-text-dimmed">Loading flags...</div>
150+
) : data ? (
151+
<div className="flex flex-col gap-1.5">
152+
{sortedFlagKeys.map((key) => {
153+
const control = data.controlTypes[key];
154+
const locked = isLocked(key);
155+
const globalValue = data.globalFlags[key as keyof typeof data.globalFlags];
156+
const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId;
157+
const globalDisplay =
158+
isWorkerGroup && data.workerGroupName && globalValue !== undefined
159+
? `${data.workerGroupName} (${String(globalValue).slice(0, 8)}...)`
160+
: globalValue !== undefined
161+
? String(globalValue)
162+
: "unset";
163+
164+
if (locked) {
165+
return (
166+
<div
167+
key={key}
168+
className="flex items-center justify-between rounded-md border border-transparent bg-charcoal-750 px-3 py-2.5"
169+
title="Global-level setting - not editable per org"
170+
>
171+
<div className="min-w-0 flex-1">
172+
<div className="truncate text-sm text-text-dimmed">{key}</div>
173+
<div className="text-xs text-charcoal-400">global: {globalDisplay}</div>
174+
</div>
175+
<LockClosedIcon className="size-4 text-charcoal-500" />
176+
</div>
177+
);
178+
}
179+
180+
const isOverridden = key in overrides;
181+
182+
return (
183+
<div
184+
key={key}
185+
className={cn(
186+
"flex items-center justify-between rounded-md border px-3 py-2.5",
187+
isOverridden
188+
? "border-indigo-500/20 bg-indigo-500/5"
189+
: "border-transparent bg-charcoal-750"
190+
)}
191+
>
192+
<div className="min-w-0 flex-1">
193+
<div
194+
className={cn(
195+
"truncate text-sm",
196+
isOverridden ? "text-text-bright" : "text-text-dimmed"
197+
)}
198+
>
199+
{key}
200+
</div>
201+
<div className="text-xs text-charcoal-400">global: {globalDisplay}</div>
202+
</div>
203+
204+
<div className="flex items-center gap-2">
205+
<Button
206+
variant="minimal/small"
207+
onClick={() => unsetFlag(key)}
208+
className={cn(!isOverridden && "invisible")}
209+
>
210+
unset
211+
</Button>
212+
213+
{isWorkerGroup && data.workerGroups ? (
214+
<WorkerGroupControl
215+
value={isOverridden ? (overrides[key] as string) : undefined}
216+
workerGroups={data.workerGroups as WorkerGroup[]}
217+
onChange={(val) => {
218+
if (val === UNSET_VALUE) {
219+
unsetFlag(key);
220+
} else {
221+
setFlagValue(key, val);
222+
}
223+
}}
224+
dimmed={!isOverridden}
225+
/>
226+
) : control.type === "boolean" ? (
227+
<BooleanControl
228+
value={isOverridden ? (overrides[key] as boolean) : undefined}
229+
onChange={(val) => setFlagValue(key, val)}
230+
dimmed={!isOverridden}
231+
/>
232+
) : control.type === "enum" ? (
233+
<EnumControl
234+
value={isOverridden ? (overrides[key] as string) : undefined}
235+
options={control.options}
236+
onChange={(val) => {
237+
if (val === UNSET_VALUE) {
238+
unsetFlag(key);
239+
} else {
240+
setFlagValue(key, val);
241+
}
242+
}}
243+
dimmed={!isOverridden}
244+
/>
245+
) : control.type === "string" ? (
246+
<StringControl
247+
value={isOverridden ? (overrides[key] as string) : ""}
248+
onChange={(val) => {
249+
if (val === "") {
250+
unsetFlag(key);
251+
} else {
252+
setFlagValue(key, val);
253+
}
254+
}}
255+
dimmed={!isOverridden}
256+
/>
257+
) : null}
258+
</div>
259+
</div>
260+
);
261+
})}
262+
</div>
263+
) : null}
264+
</div>
265+
266+
{data && (
267+
<details className="mt-2">
268+
<summary className="cursor-pointer text-xs text-text-dimmed hover:text-text-bright">
269+
Preview JSON
270+
</summary>
271+
<pre className="mt-1 max-h-40 overflow-auto rounded bg-charcoal-800 p-2 text-xs text-text-dimmed">
272+
{jsonPreview}
273+
</pre>
274+
</details>
275+
)}
276+
277+
{saveError && <Callout variant="error">{saveError}</Callout>}
278+
279+
<DialogFooter>
280+
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
281+
Cancel
282+
</Button>
283+
<Button variant="primary/small" onClick={handleSave} disabled={!isDirty || isSaving}>
284+
{isSaving ? "Saving..." : "Save changes"}
285+
</Button>
286+
</DialogFooter>
287+
</DialogContent>
288+
</Dialog>
289+
);
290+
}

0 commit comments

Comments
 (0)