Skip to content

Commit e17007e

Browse files
committed
feat: add locked flags with read-only UI, unlock checkbox for non-cloud envs
1 parent 4aec858 commit e17007e

File tree

5 files changed

+318
-87
lines changed

5 files changed

+318
-87
lines changed

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

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,27 @@ import {
1010
} from "~/components/primitives/Dialog";
1111
import { Button } from "~/components/primitives/Buttons";
1212
import { Callout } from "~/components/primitives/Callout";
13+
import { LockClosedIcon } from "@heroicons/react/20/solid";
14+
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
1315
import { cn } from "~/utils/cn";
14-
import { FEATURE_FLAG, type FlagControlType } from "~/v3/featureFlags";
15-
import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "./FlagControls";
16-
17-
const HIDDEN_FLAGS = [FEATURE_FLAG.defaultWorkerInstanceGroupId];
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";
1825

1926
type LoaderData = {
2027
org: { id: string; title: string; slug: string };
2128
orgFlags: Record<string, unknown>;
2229
globalFlags: Record<string, unknown>;
2330
controlTypes: Record<string, FlagControlType>;
31+
workerGroupName?: string;
32+
workerGroups?: WorkerGroup[];
33+
isManagedCloud?: boolean;
2434
};
2535

2636
type ActionData = {
@@ -47,6 +57,9 @@ export function FeatureFlagsDialog({
4757
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
4858
const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({});
4959
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);
5063

5164
useEffect(() => {
5265
if (open && orgId) {
@@ -104,11 +117,7 @@ export function FeatureFlagsDialog({
104117
const jsonPreview =
105118
Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2);
106119

107-
const sortedFlagKeys = data
108-
? Object.keys(data.controlTypes)
109-
.filter((key) => !HIDDEN_FLAGS.includes(key))
110-
.sort()
111-
: [];
120+
const sortedFlagKeys = data ? Object.keys(data.controlTypes).sort() : [];
112121

113122
return (
114123
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -118,16 +127,57 @@ export function FeatureFlagsDialog({
118127
Org-level overrides. Unset flags inherit from global defaults.
119128
</DialogDescription>
120129

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+
121147
<div className="max-h-[60vh] overflow-y-auto">
122148
{isLoading ? (
123149
<div className="py-8 text-center text-sm text-text-dimmed">Loading flags...</div>
124150
) : data ? (
125151
<div className="flex flex-col gap-1.5">
126152
{sortedFlagKeys.map((key) => {
127153
const control = data.controlTypes[key];
128-
const isOverridden = key in overrides;
154+
const locked = isLocked(key);
129155
const globalValue = data.globalFlags[key as keyof typeof data.globalFlags];
130-
const globalDisplay = globalValue !== undefined ? String(globalValue) : "unset";
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;
131181

132182
return (
133183
<div
@@ -160,15 +210,26 @@ export function FeatureFlagsDialog({
160210
unset
161211
</Button>
162212

163-
{control.type === "boolean" && (
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" ? (
164227
<BooleanControl
165228
value={isOverridden ? (overrides[key] as boolean) : undefined}
166229
onChange={(val) => setFlagValue(key, val)}
167230
dimmed={!isOverridden}
168231
/>
169-
)}
170-
171-
{control.type === "enum" && (
232+
) : control.type === "enum" ? (
172233
<EnumControl
173234
value={isOverridden ? (overrides[key] as string) : undefined}
174235
options={control.options}
@@ -181,9 +242,7 @@ export function FeatureFlagsDialog({
181242
}}
182243
dimmed={!isOverridden}
183244
/>
184-
)}
185-
186-
{control.type === "string" && (
245+
) : control.type === "string" ? (
187246
<StringControl
188247
value={isOverridden ? (overrides[key] as string) : ""}
189248
onChange={(val) => {
@@ -195,7 +254,7 @@ export function FeatureFlagsDialog({
195254
}}
196255
dimmed={!isOverridden}
197256
/>
198-
)}
257+
) : null}
199258
</div>
200259
</div>
201260
);

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,48 @@ export function EnumControl({
5757
);
5858
}
5959

60+
export type WorkerGroup = { id: string; name: string };
61+
62+
export function WorkerGroupControl({
63+
value,
64+
workerGroups,
65+
onChange,
66+
dimmed,
67+
}: {
68+
value: string | undefined;
69+
workerGroups: WorkerGroup[];
70+
onChange: (val: string) => void;
71+
dimmed: boolean;
72+
}) {
73+
const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)];
74+
75+
return (
76+
<Select
77+
variant="tertiary/small"
78+
value={value ?? UNSET_VALUE}
79+
setValue={onChange}
80+
items={items}
81+
text={(val) => {
82+
if (val === UNSET_VALUE) return "unset";
83+
const wg = workerGroups.find((w) => w.id === val);
84+
return wg ? wg.name : val;
85+
}}
86+
className={cn(dimmed && "opacity-50")}
87+
>
88+
{(items) =>
89+
items.map((item) => {
90+
const wg = workerGroups.find((w) => w.id === item);
91+
return (
92+
<SelectItem key={item} value={item}>
93+
{item === UNSET_VALUE ? "unset" : wg ? wg.name : item}
94+
</SelectItem>
95+
);
96+
})
97+
}
98+
</Select>
99+
);
100+
}
101+
60102
export function StringControl({
61103
value,
62104
onChange,

apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { z } from "zod";
55
import { prisma } from "~/db.server";
66
import { requireUser } from "~/services/session.server";
77
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
8-
import { validatePartialFeatureFlags, getAllFlagControlTypes } from "~/v3/featureFlags";
8+
import { FEATURE_FLAG, validatePartialFeatureFlags, getAllFlagControlTypes } from "~/v3/featureFlags";
9+
import { featuresForRequest } from "~/features.server";
910

1011
// Session-auth route for the admin feature flags dialog.
1112
// Uses replace semantics: the action writes the full flag set (or null to clear).
@@ -24,7 +25,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
2425

2526
const { organizationId } = ParamsSchema.parse(params);
2627

27-
const [organization, globalFlags] = await Promise.all([
28+
const [organization, globalFlags, workerGroups] = await Promise.all([
2829
prisma.organization.findFirst({
2930
where: { id: organizationId },
3031
select: {
@@ -35,6 +36,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3536
},
3637
}),
3738
getGlobalFlags(),
39+
prisma.workerInstanceGroup.findMany({
40+
select: { id: true, name: true },
41+
orderBy: { name: "asc" },
42+
}),
3843
]);
3944

4045
if (!organization) {
@@ -48,6 +53,21 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4853
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
4954
const controlTypes = getAllFlagControlTypes();
5055

56+
// Resolve worker group name for display
57+
const workerGroupId = (globalFlags as Record<string, unknown>)?.[
58+
FEATURE_FLAG.defaultWorkerInstanceGroupId
59+
];
60+
let workerGroupName: string | undefined;
61+
if (typeof workerGroupId === "string") {
62+
const wg = await prisma.workerInstanceGroup.findFirst({
63+
where: { id: workerGroupId },
64+
select: { name: true },
65+
});
66+
workerGroupName = wg?.name;
67+
}
68+
69+
const { isManagedCloud } = featuresForRequest(request);
70+
5171
return json({
5272
org: {
5373
id: organization.id,
@@ -57,6 +77,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
5777
orgFlags,
5878
globalFlags,
5979
controlTypes,
80+
workerGroupName,
81+
workerGroups,
82+
isManagedCloud,
6083
});
6184
}
6285

0 commit comments

Comments
 (0)