Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): admin UI for global and org-level feature flag overrides#3291
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
2a203cc2d480a5030d06bd4db4f64b6b4d295df1241101bb99f8a298cf73d10f0810229eaae9d41ebdf364d53dc9b5e7faac6e8c1415d6a10d9197c7aa44645d6518b5fb7d778bb49ef0be8aa8601e00938138aa686de9d202718d7e7428f0f901b8f749f975f3b776bef4fc535add19a8e2922fd422eaf0f80996af67a967dd8b813d6813e3f82714aec858e17007e607af80File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
| Add admin UI for viewing and editing feature flags (org-level overrides and global defaults). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,290 @@ | ||
| import { useFetcher } from "@remix-run/react"; | ||
| import { useEffect, useState } from "react"; | ||
| import stableStringify from "json-stable-stringify"; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogHeader, | ||
| DialogDescription, | ||
| DialogFooter, | ||
| } from "~/components/primitives/Dialog"; | ||
| import { Button } from "~/components/primitives/Buttons"; | ||
| import { Callout } from "~/components/primitives/Callout"; | ||
| import { LockClosedIcon } from "@heroicons/react/20/solid"; | ||
| import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; | ||
| import { cn } from "~/utils/cn"; | ||
| import { FEATURE_FLAG, ORG_LOCKED_FLAGS, type FlagControlType } from "~/v3/featureFlags"; | ||
| import { | ||
| UNSET_VALUE, | ||
| BooleanControl, | ||
| EnumControl, | ||
| StringControl, | ||
| WorkerGroupControl, | ||
| type WorkerGroup, | ||
| } from "./FlagControls"; | ||
| type LoaderData = { | ||
| org: { id: string; title: string; slug: string }; | ||
| orgFlags: Record<string, unknown>; | ||
| globalFlags: Record<string, unknown>; | ||
| controlTypes: Record<string, FlagControlType>; | ||
| workerGroupName?: string; | ||
| workerGroups?: WorkerGroup[]; | ||
| isManagedCloud?: boolean; | ||
| }; | ||
| type ActionData = { | ||
| success?: boolean; | ||
| error?: string; | ||
| }; | ||
| type FeatureFlagsDialogProps = { | ||
| orgId: string | null; | ||
| orgTitle: string; | ||
| open: boolean; | ||
| onOpenChange: (open: boolean) => void; | ||
| }; | ||
| export function FeatureFlagsDialog({ | ||
| orgId, | ||
| orgTitle, | ||
| open, | ||
| onOpenChange, | ||
| }: FeatureFlagsDialogProps) { | ||
| const loadFetcher = useFetcher<LoaderData>(); | ||
| const saveFetcher = useFetcher<ActionData>(); | ||
| const [overrides, setOverrides] = useState<Record<string, unknown>>({}); | ||
| const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({}); | ||
| const [saveError, setSaveError] = useState<string | null>(null); | ||
| const [unlocked, setUnlocked] = useState(false); | ||
| const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key); | ||
| useEffect(() => { | ||
| if (open && orgId) { | ||
| setSaveError(null); | ||
| setOverrides({}); | ||
| setInitialOverrides({}); | ||
| loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`); | ||
| } | ||
| }, [open, orgId]); | ||
| useEffect(() => { | ||
| if (loadFetcher.data) { | ||
| const loaded = loadFetcher.data.orgFlags ?? {}; | ||
| setOverrides({ ...loaded }); | ||
| setInitialOverrides({ ...loaded }); | ||
| } | ||
| }, [loadFetcher.data]); | ||
| useEffect(() => { | ||
| if (saveFetcher.data?.success) { | ||
| onOpenChange(false); | ||
| } else if (saveFetcher.data?.error) { | ||
| setSaveError(saveFetcher.data.error); | ||
| } | ||
| }, [saveFetcher.data]); | ||
| const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides); | ||
| const setFlagValue = (key: string, value: unknown) => { | ||
| setOverrides((prev) => ({ ...prev, [key]: value })); | ||
| }; | ||
| const unsetFlag = (key: string) => { | ||
| setOverrides((prev) => { | ||
| const next = { ...prev }; | ||
| delete next[key]; | ||
| return next; | ||
| }); | ||
| }; | ||
| const handleSave = () => { | ||
| if (!orgId) return; | ||
| const body = Object.keys(overrides).length === 0 ? null : overrides; | ||
| saveFetcher.submit(JSON.stringify(body), { | ||
| method: "POST", | ||
| action: `/admin/api/v2/orgs/${orgId}/feature-flags`, | ||
| encType: "application/json", | ||
| }); | ||
nicktrn marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }; | ||
nicktrn marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const data = loadFetcher.data; | ||
| const isLoading = loadFetcher.state === "loading"; | ||
| const isSaving = saveFetcher.state === "submitting"; | ||
| const jsonPreview = | ||
| Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2); | ||
| const sortedFlagKeys = data ? Object.keys(data.controlTypes).sort() : []; | ||
| return ( | ||
| <Dialog open={open} onOpenChange={onOpenChange}> | ||
| <DialogContent className="sm:max-w-lg"> | ||
| <DialogHeader>Feature flags - {orgTitle}</DialogHeader> | ||
| <DialogDescription> | ||
| Org-level overrides. Unset flags inherit from global defaults. | ||
| </DialogDescription> | ||
| {data && ( | ||
| <div className={data.isManagedCloud ? "cursor-not-allowed" : undefined}> | ||
| <CheckboxWithLabel | ||
| variant="simple/small" | ||
| label={ | ||
| data.isManagedCloud | ||
| ? "Unlock read-only flags (only in unmanaged cloud)" | ||
| : "Unlock read-only flags" | ||
| } | ||
| defaultChecked={unlocked} | ||
| onChange={setUnlocked} | ||
| disabled={data.isManagedCloud} | ||
| className={data.isManagedCloud ? "pointer-events-none" : undefined} | ||
| /> | ||
| </div> | ||
| )} | ||
| <div className="max-h-[60vh] overflow-y-auto"> | ||
| {isLoading ? ( | ||
| <div className="py-8 text-center text-sm text-text-dimmed">Loading flags...</div> | ||
| ) : data ? ( | ||
| <div className="flex flex-col gap-1.5"> | ||
| {sortedFlagKeys.map((key) => { | ||
| const control = data.controlTypes[key]; | ||
| const locked = isLocked(key); | ||
| const globalValue = data.globalFlags[key as keyof typeof data.globalFlags]; | ||
| const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId; | ||
| const globalDisplay = | ||
| isWorkerGroup && data.workerGroupName && globalValue !== undefined | ||
| ? `${data.workerGroupName} (${String(globalValue).slice(0, 8)}...)` | ||
| : globalValue !== undefined | ||
| ? String(globalValue) | ||
| : "unset"; | ||
| if (locked) { | ||
| return ( | ||
| <div | ||
| key={key} | ||
| className="flex items-center justify-between rounded-md border border-transparent bg-charcoal-750 px-3 py-2.5" | ||
| title="Global-level setting - not editable per org" | ||
| > | ||
| <div className="min-w-0 flex-1"> | ||
| <div className="truncate text-sm text-text-dimmed">{key}</div> | ||
| <div className="text-xs text-charcoal-400">global: {globalDisplay}</div> | ||
| </div> | ||
| <LockClosedIcon className="size-4 text-charcoal-500" /> | ||
| </div> | ||
| ); | ||
| } | ||
| const isOverridden = key in overrides; | ||
| return ( | ||
| <div | ||
| key={key} | ||
| className={cn( | ||
| "flex items-center justify-between rounded-md border px-3 py-2.5", | ||
| isOverridden | ||
| ? "border-indigo-500/20 bg-indigo-500/5" | ||
| : "border-transparent bg-charcoal-750" | ||
| )} | ||
| > | ||
| <div className="min-w-0 flex-1"> | ||
| <div | ||
| className={cn( | ||
| "truncate text-sm", | ||
| isOverridden ? "text-text-bright" : "text-text-dimmed" | ||
| )} | ||
| > | ||
| {key} | ||
| </div> | ||
| <div className="text-xs text-charcoal-400">global: {globalDisplay}</div> | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| <Button | ||
| variant="minimal/small" | ||
| onClick={() => unsetFlag(key)} | ||
| className={cn(!isOverridden && "invisible")} | ||
| > | ||
| unset | ||
| </Button> | ||
| {isWorkerGroup && data.workerGroups ? ( | ||
| <WorkerGroupControl | ||
| value={isOverridden ? (overrides[key] as string) : undefined} | ||
| workerGroups={data.workerGroups as WorkerGroup[]} | ||
| onChange={(val) => { | ||
| if (val === UNSET_VALUE) { | ||
| unsetFlag(key); | ||
| } else { | ||
| setFlagValue(key, val); | ||
| } | ||
| }} | ||
| dimmed={!isOverridden} | ||
| /> | ||
| ) : control.type === "boolean" ? ( | ||
| <BooleanControl | ||
| value={isOverridden ? (overrides[key] as boolean) : undefined} | ||
| onChange={(val) => setFlagValue(key, val)} | ||
| dimmed={!isOverridden} | ||
| /> | ||
| ) : control.type === "enum" ? ( | ||
| <EnumControl | ||
| value={isOverridden ? (overrides[key] as string) : undefined} | ||
| options={control.options} | ||
| onChange={(val) => { | ||
| if (val === UNSET_VALUE) { | ||
| unsetFlag(key); | ||
| } else { | ||
| setFlagValue(key, val); | ||
| } | ||
| }} | ||
| dimmed={!isOverridden} | ||
| /> | ||
| ) : control.type === "string" ? ( | ||
| <StringControl | ||
| value={isOverridden ? (overrides[key] as string) : ""} | ||
| onChange={(val) => { | ||
| if (val === "") { | ||
| unsetFlag(key); | ||
| } else { | ||
| setFlagValue(key, val); | ||
| } | ||
| }} | ||
| dimmed={!isOverridden} | ||
| /> | ||
| ) : null} | ||
| </div> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| {data && ( | ||
| <details className="mt-2"> | ||
| <summary className="cursor-pointer text-xs text-text-dimmed hover:text-text-bright"> | ||
| Preview JSON | ||
| </summary> | ||
| <pre className="mt-1 max-h-40 overflow-auto rounded bg-charcoal-800 p-2 text-xs text-text-dimmed"> | ||
| {jsonPreview} | ||
| </pre> | ||
| </details> | ||
| )} | ||
| {saveError && <Callout variant="error">{saveError}</Callout>} | ||
| <DialogFooter> | ||
| <Button variant="tertiary/small" onClick={() => onOpenChange(false)}> | ||
| Cancel | ||
| </Button> | ||
| <Button variant="primary/small" onClick={handleSave} disabled={!isDirty || isSaving}> | ||
| {isSaving ? "Saving..." : "Save changes"} | ||
| </Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.