-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Merged
Merged
Changes from all commits
Commits
Show all changes
42 commits
Select commit
Hold shift + click to select a range
2a203cc
feat: add flag catalog introspection utilities for UI rendering
nicktrn 2d480a5
feat: include featureFlags in admin org query
nicktrn 030d06b
feat: add session-auth resource route for org feature flags
nicktrn d4db4f6
feat: add FeatureFlagsDialog component for org flag editing
nicktrn 4b6b4d2
feat: wire up feature flags dialog in admin orgs page
nicktrn 95df124
fix: use Prisma.JsonNull for clearing flags, fix enum display showing…
nicktrn 1101bb9
fix: inline action buttons with gap, add padding below dialog header
nicktrn 9f8a298
fix: move DialogDescription out of DialogHeader to match codebase pat…
nicktrn cf73d10
fix: hide defaultWorkerInstanceGroupId from UI, prevent border jump o…
nicktrn f081022
fix: always reserve space for unset button to prevent row height jump
nicktrn 9eaae9d
refactor: simplify dialog - drop unnecessary hooks, parallelize DB ca…
nicktrn 41ebdf3
fix: use findFirst instead of findUnique for org lookup
nicktrn 64d53dc
docs: add Prisma and React pattern guidelines to webapp CLAUDE.md
nicktrn 9b5e7fa
refactor: replace IIFE with if/else in feature flags action
nicktrn ac6e8c1
fix: show save error callout in feature flags dialog
nicktrn 415d6a1
fix: clear save error when feature flags dialog opens
nicktrn 0d9197c
fix: remove unnecessary useMemo wrapping trivial isDirty check
nicktrn 7aa4464
chore: add server-changes file for admin feature flags
nicktrn 5d6518b
feat: add global feature flags admin tab with shared flag controls
nicktrn 5fb7d77
fix: add consumer default hint, use stable stringify for dirty check
nicktrn 8bb49ef
fix: use stable stringify for dirty check, fix Prisma type error
nicktrn 0be8aa8
feat: add worker group dropdown and confirmation dialog with diff view
nicktrn 601e009
fix: return validation error instead of throwing to populate fetcher.…
nicktrn 38138aa
feat: add discard button to reset unsaved changes
nicktrn 686de9d
refactor: batch action DB ops in transaction, use validated data, use…
nicktrn 202718d
fix: add Change type parameter to flatMap for typecheck
nicktrn 7e7428f
fix: validate payloads upfront, guard against array inputs clearing f…
nicktrn 0f901b8
refactor: use zod schema for request payload validation
nicktrn f749f97
fix: guard request.json() with try-catch to match newer admin route p…
nicktrn 5f3b776
fix: move FEATURE_FLAG to shared module to prevent client-side crash
nicktrn bef4fc5
fix: use sentence case for dialog headers and buttons to match codeba…
nicktrn 35add19
fix: reduce spacing above change list in confirm dialog
nicktrn a8e2922
fix: use sentence case for dialog header
nicktrn fd422ea
refactor: cleaner feature flag server module split
nicktrn f0f8099
refactor: rename feature flags route to v2, match v1 param naming
nicktrn 6af67a9
chore: update server-changes to include global flags tab
nicktrn 67dd8b8
fix: replace findUnique with findFirst in v1 org feature flags route
nicktrn 13d6813
fix: address review feedback - findFirst, cross-org state reset, stal…
nicktrn e3f8271
merge: resolve conflict, add hasComputeAccess to shared catalog
nicktrn 4aec858
merge: resolve conflict, add hasPrivateConnections to shared catalog
nicktrn e17007e
feat: add locked flags with read-only UI, unlock checkbox for non-clo…
nicktrn 607af80
fix: always skip locked flags in delete loop to prevent accidental de…
nicktrn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
290 changes: 290 additions & 0 deletions
290
apps/webapp/app/components/admin/FeatureFlagsDialog.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
Show resolved
Hide resolved
|
||
| }; | ||
nicktrn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.