Skip to content
Merged
Show file tree
Hide file tree
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 Mar 28, 2026
2d480a5
feat: include featureFlags in admin org query
nicktrn Mar 28, 2026
030d06b
feat: add session-auth resource route for org feature flags
nicktrn Mar 28, 2026
d4db4f6
feat: add FeatureFlagsDialog component for org flag editing
nicktrn Mar 28, 2026
4b6b4d2
feat: wire up feature flags dialog in admin orgs page
nicktrn Mar 28, 2026
95df124
fix: use Prisma.JsonNull for clearing flags, fix enum display showing…
nicktrn Mar 28, 2026
1101bb9
fix: inline action buttons with gap, add padding below dialog header
nicktrn Mar 28, 2026
9f8a298
fix: move DialogDescription out of DialogHeader to match codebase pat…
nicktrn Mar 28, 2026
cf73d10
fix: hide defaultWorkerInstanceGroupId from UI, prevent border jump o…
nicktrn Mar 28, 2026
f081022
fix: always reserve space for unset button to prevent row height jump
nicktrn Mar 28, 2026
9eaae9d
refactor: simplify dialog - drop unnecessary hooks, parallelize DB ca…
nicktrn Mar 28, 2026
41ebdf3
fix: use findFirst instead of findUnique for org lookup
nicktrn Mar 28, 2026
64d53dc
docs: add Prisma and React pattern guidelines to webapp CLAUDE.md
nicktrn Mar 28, 2026
9b5e7fa
refactor: replace IIFE with if/else in feature flags action
nicktrn Mar 28, 2026
ac6e8c1
fix: show save error callout in feature flags dialog
nicktrn Mar 28, 2026
415d6a1
fix: clear save error when feature flags dialog opens
nicktrn Mar 28, 2026
0d9197c
fix: remove unnecessary useMemo wrapping trivial isDirty check
nicktrn Mar 28, 2026
7aa4464
chore: add server-changes file for admin feature flags
nicktrn Mar 28, 2026
5d6518b
feat: add global feature flags admin tab with shared flag controls
nicktrn Mar 29, 2026
5fb7d77
fix: add consumer default hint, use stable stringify for dirty check
nicktrn Mar 29, 2026
8bb49ef
fix: use stable stringify for dirty check, fix Prisma type error
nicktrn Mar 29, 2026
0be8aa8
feat: add worker group dropdown and confirmation dialog with diff view
nicktrn Mar 29, 2026
601e009
fix: return validation error instead of throwing to populate fetcher.…
nicktrn Mar 29, 2026
38138aa
feat: add discard button to reset unsaved changes
nicktrn Mar 29, 2026
686de9d
refactor: batch action DB ops in transaction, use validated data, use…
nicktrn Mar 29, 2026
202718d
fix: add Change type parameter to flatMap for typecheck
nicktrn Mar 29, 2026
7e7428f
fix: validate payloads upfront, guard against array inputs clearing f…
nicktrn Mar 29, 2026
0f901b8
refactor: use zod schema for request payload validation
nicktrn Mar 29, 2026
f749f97
fix: guard request.json() with try-catch to match newer admin route p…
nicktrn Mar 29, 2026
5f3b776
fix: move FEATURE_FLAG to shared module to prevent client-side crash
nicktrn Mar 29, 2026
bef4fc5
fix: use sentence case for dialog headers and buttons to match codeba…
nicktrn Mar 29, 2026
35add19
fix: reduce spacing above change list in confirm dialog
nicktrn Mar 29, 2026
a8e2922
fix: use sentence case for dialog header
nicktrn Mar 29, 2026
fd422ea
refactor: cleaner feature flag server module split
nicktrn Mar 29, 2026
f0f8099
refactor: rename feature flags route to v2, match v1 param naming
nicktrn Mar 29, 2026
6af67a9
chore: update server-changes to include global flags tab
nicktrn Mar 29, 2026
67dd8b8
fix: replace findUnique with findFirst in v1 org feature flags route
nicktrn Mar 29, 2026
13d6813
fix: address review feedback - findFirst, cross-org state reset, stal…
nicktrn Mar 29, 2026
e3f8271
merge: resolve conflict, add hasComputeAccess to shared catalog
nicktrn Mar 29, 2026
4aec858
merge: resolve conflict, add hasPrivateConnections to shared catalog
nicktrn Mar 30, 2026
e17007e
feat: add locked flags with read-only UI, unlock checkbox for non-clo…
nicktrn Mar 30, 2026
607af80
fix: always skip locked flags in delete loop to prevent accidental de…
nicktrn Mar 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/admin-feature-flags-dialog.md
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).
9 changes: 9 additions & 0 deletions apps/webapp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,12 @@ The `app/v3/` directory name is misleading - most code is actively used by V2. O
- `app/v3/sharedSocketConnection.ts`

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.

## Prisma Query Patterns

- **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.

## React Patterns

- 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.
- Use named constants for sentinel/placeholder values (e.g. `const UNSET_VALUE = "__unset__"`) instead of raw string literals scattered across comparisons.
290 changes: 290 additions & 0 deletions apps/webapp/app/components/admin/FeatureFlagsDialog.tsx
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",
});
};

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>
);
}
Loading
Loading