From 2a203cc8d96b21944de81b8a4d3b75f977e72095 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:57:46 +0000 Subject: [PATCH 01/40] feat: add flag catalog introspection utilities for UI rendering --- apps/webapp/app/v3/featureFlags.server.ts | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/webapp/app/v3/featureFlags.server.ts b/apps/webapp/app/v3/featureFlags.server.ts index f32f34c64b8..215e7548d58 100644 --- a/apps/webapp/app/v3/featureFlags.server.ts +++ b/apps/webapp/app/v3/featureFlags.server.ts @@ -162,6 +162,34 @@ export function validatePartialFeatureFlags(values: Record) { return FeatureFlagCatalogSchema.partial().safeParse(values); } +// Utility types for catalog-driven UI rendering +export type FlagControlType = + | { type: "boolean" } + | { type: "enum"; options: string[] } + | { type: "string" }; + +export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType { + const typeName = schema._def.typeName; + + if (typeName === "ZodBoolean") { + return { type: "boolean" }; + } + + if (typeName === "ZodEnum") { + return { type: "enum", options: schema._def.values as string[] }; + } + + return { type: "string" }; +} + +export function getAllFlagControlTypes(): Record { + const result: Record = {}; + for (const [key, schema] of Object.entries(FeatureFlagCatalog)) { + result[key] = getFlagControlType(schema); + } + return result; +} + // Utility function to set multiple feature flags at once export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma) { return async function setMultipleFlags( From 2d480a5c69ff36b0e4f9fa78dda02203e4ef7981 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:58:46 +0000 Subject: [PATCH 02/40] feat: include featureFlags in admin org query --- apps/webapp/app/models/admin.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts index bd3adc8cbf2..e34387266f7 100644 --- a/apps/webapp/app/models/admin.server.ts +++ b/apps/webapp/app/models/admin.server.ts @@ -133,6 +133,7 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se v2Enabled: true, v3Enabled: true, deletedAt: true, + featureFlags: true, members: { select: { user: { From 030d06bd4df43ea8ae88113b8ef9d4e90bf03112 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:59:26 +0000 Subject: [PATCH 03/40] feat: add session-auth resource route for org feature flags --- .../admin.api.orgs.$orgId.feature-flags.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts diff --git a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts new file mode 100644 index 00000000000..67ba1b7a47d --- /dev/null +++ b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts @@ -0,0 +1,100 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireUser } from "~/services/session.server"; +import { + flags as getGlobalFlags, + validatePartialFeatureFlags, + getAllFlagControlTypes, +} from "~/v3/featureFlags.server"; + +const ParamsSchema = z.object({ + orgId: z.string(), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + throw new Response("Unauthorized", { status: 403 }); + } + + const { orgId } = ParamsSchema.parse(params); + + const organization = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { + id: true, + title: true, + slug: true, + featureFlags: true, + }, + }); + + if (!organization) { + throw new Response("Organization not found", { status: 404 }); + } + + const orgFlagsResult = organization.featureFlags + ? validatePartialFeatureFlags(organization.featureFlags as Record) + : ({ success: false } as const); + + const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {}; + const globalFlags = await getGlobalFlags(); + const controlTypes = getAllFlagControlTypes(); + + return json({ + org: { + id: organization.id, + title: organization.title, + slug: organization.slug, + }, + orgFlags, + globalFlags, + controlTypes, + }); +} + +export async function action({ request, params }: ActionFunctionArgs) { + const user = await requireUser(request); + if (!user.admin) { + throw new Response("Unauthorized", { status: 403 }); + } + + const { orgId } = ParamsSchema.parse(params); + + const organization = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { id: true }, + }); + + if (!organization) { + throw new Response("Organization not found", { status: 404 }); + } + + const body = await request.json(); + + // body is the full overrides object (or null to clear all) + if (body === null || (typeof body === "object" && Object.keys(body).length === 0)) { + await prisma.organization.update({ + where: { id: orgId }, + data: { featureFlags: null }, + }); + return json({ success: true }); + } + + const validationResult = validatePartialFeatureFlags(body as Record); + if (!validationResult.success) { + return json( + { error: "Invalid feature flags", details: validationResult.error.issues }, + { status: 400 } + ); + } + + await prisma.organization.update({ + where: { id: orgId }, + data: { featureFlags: validationResult.data }, + }); + + return json({ success: true }); +} From d4db4f62f2fd3c11a584737f6f36b2a1fb297d51 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:01:40 +0000 Subject: [PATCH 04/40] feat: add FeatureFlagsDialog component for org flag editing --- .../components/admin/FeatureFlagsDialog.tsx | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 apps/webapp/app/components/admin/FeatureFlagsDialog.tsx diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx new file mode 100644 index 00000000000..53c6d6dfc63 --- /dev/null +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -0,0 +1,312 @@ +import { useFetcher } from "@remix-run/react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "~/components/primitives/Dialog"; +import { Button } from "~/components/primitives/Buttons"; +import { Switch } from "~/components/primitives/Switch"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { Input } from "~/components/primitives/Input"; +import { cn } from "~/utils/cn"; +import type { FlagControlType } from "~/v3/featureFlags.server"; + +type LoaderData = { + org: { id: string; title: string; slug: string }; + orgFlags: Record; + globalFlags: Record; + controlTypes: Record; +}; + +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(); + const saveFetcher = useFetcher(); + + // Local state for edits - keyed by flag name, value is the override or undefined (unset) + const [overrides, setOverrides] = useState>({}); + const [initialOverrides, setInitialOverrides] = useState>({}); + + // Load flags when dialog opens + useEffect(() => { + if (open && orgId) { + loadFetcher.load(`/admin/api/orgs/${orgId}/feature-flags`); + } + }, [open, orgId]); + + // Sync loaded data into local state + useEffect(() => { + if (loadFetcher.data) { + const loaded = loadFetcher.data.orgFlags ?? {}; + setOverrides({ ...loaded }); + setInitialOverrides({ ...loaded }); + } + }, [loadFetcher.data]); + + // Close on successful save + useEffect(() => { + if (saveFetcher.data?.success) { + onOpenChange(false); + } + }, [saveFetcher.data]); + + const isDirty = useMemo(() => { + return JSON.stringify(overrides) !== JSON.stringify(initialOverrides); + }, [overrides, initialOverrides]); + + const setFlagValue = useCallback((key: string, value: unknown) => { + setOverrides((prev) => ({ ...prev, [key]: value })); + }, []); + + const unsetFlag = useCallback((key: string) => { + setOverrides((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + }, []); + + const handleSave = useCallback(() => { + if (!orgId) return; + + const body = Object.keys(overrides).length === 0 ? null : overrides; + + saveFetcher.submit(JSON.stringify(body), { + method: "POST", + action: `/admin/api/orgs/${orgId}/feature-flags`, + encType: "application/json", + }); + }, [orgId, overrides, saveFetcher]); + + const data = loadFetcher.data; + const isLoading = loadFetcher.state === "loading"; + const isSaving = saveFetcher.state === "submitting"; + + // Build JSON preview + const jsonPreview = useMemo(() => { + if (Object.keys(overrides).length === 0) return "null"; + return JSON.stringify(overrides, null, 2); + }, [overrides]); + + // Sort flag keys alphabetically + const sortedFlagKeys = useMemo(() => { + if (!data) return []; + return Object.keys(data.controlTypes).sort(); + }, [data]); + + return ( + + + + Feature Flags - {orgTitle} + + Org-level overrides. Unset flags inherit from global defaults. + + + +
+ {isLoading ? ( +
Loading flags...
+ ) : data ? ( +
+ {sortedFlagKeys.map((key) => { + const control = data.controlTypes[key]; + const isOverridden = key in overrides; + const globalValue = data.globalFlags[key as keyof typeof data.globalFlags]; + const globalDisplay = + globalValue !== undefined ? String(globalValue) : "unset"; + + return ( +
+
+
+ {key} +
+
global: {globalDisplay}
+
+ +
+ {isOverridden && ( + + )} + + {control.type === "boolean" && ( + setFlagValue(key, val)} + dimmed={!isOverridden} + /> + )} + + {control.type === "enum" && ( + { + if (val === "__unset__") { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isOverridden} + /> + )} + + {control.type === "string" && ( + { + if (val === "") { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isOverridden} + /> + )} +
+
+ ); + })} +
+ ) : null} +
+ + {/* JSON Preview */} + {data && ( +
+ + Preview JSON + +
+              {jsonPreview}
+            
+
+ )} + + + + + +
+
+ ); +} + +// --- Sub-components --- + +function BooleanControl({ + value, + onChange, + dimmed, +}: { + value: boolean | undefined; + onChange: (val: boolean) => void; + dimmed: boolean; +}) { + return ( + + ); +} + +function EnumControl({ + value, + options, + onChange, + dimmed, +}: { + value: string | undefined; + options: string[]; + onChange: (val: string) => void; + dimmed: boolean; +}) { + const items = ["__unset__", ...options]; + + return ( + + ); +} + +function StringControl({ + value, + onChange, + dimmed, +}: { + value: string; + onChange: (val: string) => void; + dimmed: boolean; +}) { + return ( + onChange(e.target.value)} + placeholder="unset" + className={cn("w-40", dimmed && "opacity-50")} + /> + ); +} From 4b6b4d2a02669129f0e49b1c4d9c28e1b59e0acf Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:02:57 +0000 Subject: [PATCH 05/40] feat: wire up feature flags dialog in admin orgs page --- apps/webapp/app/routes/admin.orgs.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index afdcf5e97c9..a7e7b077623 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -2,7 +2,9 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { useState } from "react"; import { z } from "zod"; +import { FeatureFlagsDialog } from "~/components/admin/FeatureFlagsDialog"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; import { Input } from "~/components/primitives/Input"; @@ -46,6 +48,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export default function AdminDashboardRoute() { const { organizations, filters, page, pageCount } = useTypedLoaderData(); + const [flagsOrgId, setFlagsOrgId] = useState(null); + const [flagsOrgTitle, setFlagsOrgTitle] = useState(""); + const [flagsOpen, setFlagsOpen] = useState(false); + + const openFlagsDialog = (orgId: string, orgTitle: string) => { + setFlagsOrgId(orgId); + setFlagsOrgTitle(orgTitle); + setFlagsOpen(true); + }; + return (
{org.v3Enabled ? "✅" : ""} {org.deletedAt ? "☠️" : ""} + +
); } From 95df1245c47238c1b1279e0af4cd4c8079f86767 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:08:12 +0000 Subject: [PATCH 06/40] fix: use Prisma.JsonNull for clearing flags, fix enum display showing __unset__ --- apps/webapp/app/components/admin/FeatureFlagsDialog.tsx | 1 + apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 53c6d6dfc63..36ec8e81d88 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -278,6 +278,7 @@ function EnumControl({ value={value ?? "__unset__"} setValue={onChange} items={items} + text={(val) => (val === "__unset__" ? "unset" : val)} className={cn(dimmed && "opacity-50")} > {(items) => diff --git a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts index 67ba1b7a47d..c43d2eb438d 100644 --- a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts @@ -1,5 +1,6 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; +import { Prisma } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUser } from "~/services/session.server"; @@ -78,7 +79,7 @@ export async function action({ request, params }: ActionFunctionArgs) { if (body === null || (typeof body === "object" && Object.keys(body).length === 0)) { await prisma.organization.update({ where: { id: orgId }, - data: { featureFlags: null }, + data: { featureFlags: Prisma.JsonNull }, }); return json({ success: true }); } From 1101bb9fc942d4b3f4fe4fa38a718577cf8d6658 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:09:48 +0000 Subject: [PATCH 07/40] fix: inline action buttons with gap, add padding below dialog header --- apps/webapp/app/components/admin/FeatureFlagsDialog.tsx | 2 +- apps/webapp/app/routes/admin.orgs.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 36ec8e81d88..357d0ed8d16 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -124,7 +124,7 @@ export function FeatureFlagsDialog({ -
+
{isLoading ? (
Loading flags...
) : data ? ( diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index a7e7b077623..f52cd85413b 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -125,16 +125,15 @@ export default function AdminDashboardRoute() { {org.v3Enabled ? "✅" : ""} {org.deletedAt ? "☠️" : ""} +
Impersonate +
); From 9f8a29880de1fe689383e0a48e859a3feb531348 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:10:54 +0000 Subject: [PATCH 08/40] fix: move DialogDescription out of DialogHeader to match codebase pattern --- .../app/components/admin/FeatureFlagsDialog.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 357d0ed8d16..b05ac9f22a8 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -4,7 +4,6 @@ import { Dialog, DialogContent, DialogHeader, - DialogTitle, DialogDescription, DialogFooter, } from "~/components/primitives/Dialog"; @@ -117,14 +116,12 @@ export function FeatureFlagsDialog({ return ( - - Feature Flags - {orgTitle} - - Org-level overrides. Unset flags inherit from global defaults. - - + Feature Flags - {orgTitle} + + Org-level overrides. Unset flags inherit from global defaults. + -
+
{isLoading ? (
Loading flags...
) : data ? ( From cf73d10f605fcd5b516b2b1f047a2afc6b17baa6 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:14:01 +0000 Subject: [PATCH 09/40] fix: hide defaultWorkerInstanceGroupId from UI, prevent border jump on override --- .../webapp/app/components/admin/FeatureFlagsDialog.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index b05ac9f22a8..f147c051fe6 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -126,7 +126,9 @@ export function FeatureFlagsDialog({
Loading flags...
) : data ? (
- {sortedFlagKeys.map((key) => { + {sortedFlagKeys + .filter((key) => key !== "defaultWorkerInstanceGroupId") + .map((key) => { const control = data.controlTypes[key]; const isOverridden = key in overrides; const globalValue = data.globalFlags[key as keyof typeof data.globalFlags]; @@ -137,10 +139,10 @@ export function FeatureFlagsDialog({
From f081022ff4699b32e9ce8c43841dc32240b4e0cf Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:16:24 +0000 Subject: [PATCH 10/40] fix: always reserve space for unset button to prevent row height jump --- .../app/components/admin/FeatureFlagsDialog.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index f147c051fe6..b4ba43cda71 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -158,11 +158,13 @@ export function FeatureFlagsDialog({
- {isOverridden && ( - - )} + {control.type === "boolean" && ( Date: Sat, 28 Mar 2026 20:22:31 +0000 Subject: [PATCH 11/40] refactor: simplify dialog - drop unnecessary hooks, parallelize DB calls, extract constants --- .../components/admin/FeatureFlagsDialog.tsx | 56 +++++++-------- apps/webapp/app/models/admin.server.ts | 1 - .../admin.api.orgs.$orgId.feature-flags.ts | 70 +++++++++---------- apps/webapp/app/routes/admin.orgs.tsx | 39 +++++------ 4 files changed, 76 insertions(+), 90 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index b4ba43cda71..cce17b560d9 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -1,5 +1,5 @@ import { useFetcher } from "@remix-run/react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Dialog, DialogContent, @@ -14,6 +14,10 @@ import { Input } from "~/components/primitives/Input"; import { cn } from "~/utils/cn"; import type { FlagControlType } from "~/v3/featureFlags.server"; +const UNSET_VALUE = "__unset__"; + +const HIDDEN_FLAGS = ["defaultWorkerInstanceGroupId"]; + type LoaderData = { org: { id: string; title: string; slug: string }; orgFlags: Record; @@ -42,18 +46,15 @@ export function FeatureFlagsDialog({ const loadFetcher = useFetcher(); const saveFetcher = useFetcher(); - // Local state for edits - keyed by flag name, value is the override or undefined (unset) const [overrides, setOverrides] = useState>({}); const [initialOverrides, setInitialOverrides] = useState>({}); - // Load flags when dialog opens useEffect(() => { if (open && orgId) { loadFetcher.load(`/admin/api/orgs/${orgId}/feature-flags`); } }, [open, orgId]); - // Sync loaded data into local state useEffect(() => { if (loadFetcher.data) { const loaded = loadFetcher.data.orgFlags ?? {}; @@ -62,7 +63,6 @@ export function FeatureFlagsDialog({ } }, [loadFetcher.data]); - // Close on successful save useEffect(() => { if (saveFetcher.data?.success) { onOpenChange(false); @@ -73,45 +73,40 @@ export function FeatureFlagsDialog({ return JSON.stringify(overrides) !== JSON.stringify(initialOverrides); }, [overrides, initialOverrides]); - const setFlagValue = useCallback((key: string, value: unknown) => { + const setFlagValue = (key: string, value: unknown) => { setOverrides((prev) => ({ ...prev, [key]: value })); - }, []); + }; - const unsetFlag = useCallback((key: string) => { + const unsetFlag = (key: string) => { setOverrides((prev) => { const next = { ...prev }; delete next[key]; return next; }); - }, []); + }; - const handleSave = useCallback(() => { + const handleSave = () => { if (!orgId) return; - const body = Object.keys(overrides).length === 0 ? null : overrides; - saveFetcher.submit(JSON.stringify(body), { method: "POST", action: `/admin/api/orgs/${orgId}/feature-flags`, encType: "application/json", }); - }, [orgId, overrides, saveFetcher]); + }; const data = loadFetcher.data; const isLoading = loadFetcher.state === "loading"; const isSaving = saveFetcher.state === "submitting"; - // Build JSON preview - const jsonPreview = useMemo(() => { - if (Object.keys(overrides).length === 0) return "null"; - return JSON.stringify(overrides, null, 2); - }, [overrides]); + const jsonPreview = + Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2); - // Sort flag keys alphabetically - const sortedFlagKeys = useMemo(() => { - if (!data) return []; - return Object.keys(data.controlTypes).sort(); - }, [data]); + const sortedFlagKeys = data + ? Object.keys(data.controlTypes) + .filter((key) => !HIDDEN_FLAGS.includes(key)) + .sort() + : []; return ( @@ -126,9 +121,7 @@ export function FeatureFlagsDialog({
Loading flags...
) : data ? (
- {sortedFlagKeys - .filter((key) => key !== "defaultWorkerInstanceGroupId") - .map((key) => { + {sortedFlagKeys.map((key) => { const control = data.controlTypes[key]; const isOverridden = key in overrides; const globalValue = data.globalFlags[key as keyof typeof data.globalFlags]; @@ -179,7 +172,7 @@ export function FeatureFlagsDialog({ value={isOverridden ? (overrides[key] as string) : undefined} options={control.options} onChange={(val) => { - if (val === "__unset__") { + if (val === UNSET_VALUE) { unsetFlag(key); } else { setFlagValue(key, val); @@ -210,7 +203,6 @@ export function FeatureFlagsDialog({ ) : null}
- {/* JSON Preview */} {data && (
@@ -271,21 +263,21 @@ function EnumControl({ onChange: (val: string) => void; dimmed: boolean; }) { - const items = ["__unset__", ...options]; + const items = [UNSET_VALUE, ...options]; return ( (val === UNSET_VALUE ? "unset" : val)} - className={cn(dimmed && "opacity-50")} - > - {(items) => - items.map((item) => ( - - {item === UNSET_VALUE ? "unset" : item} - - )) - } - - ); -} - -function StringControl({ - value, - onChange, - dimmed, -}: { - value: string; - onChange: (val: string) => void; - dimmed: boolean; -}) { - return ( - onChange(e.target.value)} - placeholder="unset" - className={cn("w-40", dimmed && "opacity-50")} - /> - ); -} diff --git a/apps/webapp/app/components/admin/FlagControls.tsx b/apps/webapp/app/components/admin/FlagControls.tsx new file mode 100644 index 00000000000..6a325df838a --- /dev/null +++ b/apps/webapp/app/components/admin/FlagControls.tsx @@ -0,0 +1,78 @@ +import { Switch } from "~/components/primitives/Switch"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { Input } from "~/components/primitives/Input"; +import { cn } from "~/utils/cn"; + +export const UNSET_VALUE = "__unset__"; + +export function BooleanControl({ + value, + onChange, + dimmed, +}: { + value: boolean | undefined; + onChange: (val: boolean) => void; + dimmed: boolean; +}) { + return ( + + ); +} + +export function EnumControl({ + value, + options, + onChange, + dimmed, +}: { + value: string | undefined; + options: string[]; + onChange: (val: string) => void; + dimmed: boolean; +}) { + const items = [UNSET_VALUE, ...options]; + + return ( + + ); +} + +export function StringControl({ + value, + onChange, + dimmed, +}: { + value: string; + onChange: (val: string) => void; + dimmed: boolean; +}) { + return ( + onChange(e.target.value)} + placeholder="unset" + className={cn("w-40", dimmed && "opacity-50")} + /> + ); +} diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx new file mode 100644 index 00000000000..fdee2716881 --- /dev/null +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -0,0 +1,216 @@ +import { useFetcher } from "@remix-run/react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { useEffect, useState } from "react"; +import { json } from "@remix-run/server-runtime"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { prisma } from "~/db.server"; +import { requireUser } from "~/services/session.server"; +import { + flags as getGlobalFlags, + getAllFlagControlTypes, + validatePartialFeatureFlags, +} from "~/v3/featureFlags.server"; +import type { FlagControlType } from "~/v3/featureFlags.server"; +import { Button } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { cn } from "~/utils/cn"; +import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "~/components/admin/FlagControls"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + + const globalFlags = await getGlobalFlags(); + const controlTypes = getAllFlagControlTypes(); + + return typedjson({ globalFlags, controlTypes }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = await requireUser(request); + if (!user.admin) { + throw new Response("Unauthorized", { status: 403 }); + } + + const body = await request.json(); + const { flags: newFlags } = body as { flags: Record }; + + const controlTypes = getAllFlagControlTypes(); + const catalogKeys = Object.keys(controlTypes); + + // For each catalog key: if value is present in newFlags, upsert it. If absent, delete the row. + for (const key of catalogKeys) { + if (key in newFlags) { + const value = newFlags[key]; + // Validate the value against its schema + const partial = { [key]: value }; + const result = validatePartialFeatureFlags(partial); + if (result.success) { + await prisma.featureFlag.upsert({ + where: { key }, + create: { key, value: value as any }, + update: { value: value as any }, + }); + } + } else { + // Unset - delete the row if it exists + await prisma.featureFlag.deleteMany({ where: { key } }); + } + } + + return json({ success: true }); +}; + +export default function AdminFeatureFlagsRoute() { + const { globalFlags, controlTypes } = useTypedLoaderData(); + const saveFetcher = useFetcher<{ success?: boolean; error?: string }>(); + + const [values, setValues] = useState>({}); + const [initialValues, setInitialValues] = useState>({}); + const [saveError, setSaveError] = useState(null); + + // Sync loader data into local state + useEffect(() => { + const loaded = (globalFlags ?? {}) as Record; + setValues({ ...loaded }); + setInitialValues({ ...loaded }); + }, [globalFlags]); + + useEffect(() => { + if (saveFetcher.data?.success) { + setSaveError(null); + // Update initial to match saved state + setInitialValues({ ...values }); + } else if (saveFetcher.data?.error) { + setSaveError(saveFetcher.data.error); + } + }, [saveFetcher.data]); + + const isDirty = JSON.stringify(values) !== JSON.stringify(initialValues); + const isSaving = saveFetcher.state === "submitting"; + + const setFlagValue = (key: string, value: unknown) => { + setValues((prev) => ({ ...prev, [key]: value })); + }; + + const unsetFlag = (key: string) => { + setValues((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + }; + + const handleSave = () => { + saveFetcher.submit(JSON.stringify({ flags: values }), { + method: "POST", + encType: "application/json", + }); + }; + + const sortedFlagKeys = Object.keys(controlTypes as Record).sort(); + + return ( +
+
+

+ Global defaults for all organizations. Org-level overrides take precedence. +

+ +
+ {sortedFlagKeys.map((key) => { + const control = (controlTypes as Record)[key]; + const isSet = key in values; + + return ( +
+
+
+ {key} +
+
+ {isSet ? `value: ${String(values[key])}` : "not set"} +
+
+ +
+ + + {control.type === "boolean" && ( + setFlagValue(key, val)} + dimmed={!isSet} + /> + )} + + {control.type === "enum" && ( + { + if (val === UNSET_VALUE) { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isSet} + /> + )} + + {control.type === "string" && ( + { + if (val === "") { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isSet} + /> + )} +
+
+ ); + })} +
+ + {saveError && {saveError}} + +
+ +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx index c03de5fc537..4cd2deca533 100644 --- a/apps/webapp/app/routes/admin.tsx +++ b/apps/webapp/app/routes/admin.tsx @@ -36,6 +36,10 @@ export default function Page() { label: "LLM Models", to: "/admin/llm-models", }, + { + label: "Feature Flags", + to: "/admin/feature-flags", + }, { label: "Notifications", to: "/admin/notifications", From 5fb7d770fc384ae99f15e2536c1b09e76360b160 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:18:00 +0100 Subject: [PATCH 20/40] fix: add consumer default hint, use stable stringify for dirty check --- apps/webapp/app/routes/admin.feature-flags.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index fdee2716881..29d32789298 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -1,6 +1,7 @@ import { useFetcher } from "@remix-run/react"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useEffect, useState } from "react"; +import stableStringify from "json-stable-stringify"; import { json } from "@remix-run/server-runtime"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { prisma } from "~/db.server"; @@ -88,7 +89,7 @@ export default function AdminFeatureFlagsRoute() { } }, [saveFetcher.data]); - const isDirty = JSON.stringify(values) !== JSON.stringify(initialValues); + const isDirty = stableStringify(values) !== stableStringify(initialValues); const isSaving = saveFetcher.state === "submitting"; const setFlagValue = (key: string, value: unknown) => { @@ -117,6 +118,7 @@ export default function AdminFeatureFlagsRoute() {

Global defaults for all organizations. Org-level overrides take precedence. + When not set, each consumer uses its own default.

From 8bb49efc24186cde56f225a29a4261c646200ff4 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:20:32 +0100 Subject: [PATCH 21/40] fix: use stable stringify for dirty check, fix Prisma type error --- .../components/admin/FeatureFlagsDialog.tsx | 3 +- .../admin.api.orgs.$orgId.feature-flags.ts | 2 +- apps/webapp/package.json | 1 + pnpm-lock.yaml | 32 ++++++++++++------- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 764e2f9c05d..db7967e8fcb 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -1,5 +1,6 @@ import { useFetcher } from "@remix-run/react"; import { useEffect, useState } from "react"; +import stableStringify from "json-stable-stringify"; import { Dialog, DialogContent, @@ -70,7 +71,7 @@ export function FeatureFlagsDialog({ } }, [saveFetcher.data]); - const isDirty = JSON.stringify(overrides) !== JSON.stringify(initialOverrides); + const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides); const setFlagValue = (key: string, value: unknown) => { setOverrides((prev) => ({ ...prev, [key]: value })); diff --git a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts index c878a658675..83154fb3ae7 100644 --- a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts @@ -85,7 +85,7 @@ export async function action({ request, params }: ActionFunctionArgs) { try { await prisma.organization.update({ where: { id: orgId }, - data: { featureFlags }, + data: { featureFlags: featureFlags as Prisma.InputJsonValue }, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { diff --git a/apps/webapp/package.json b/apps/webapp/package.json index eaa7ebb42c7..bb1a734ae9e 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -161,6 +161,7 @@ "ioredis": "^5.3.2", "isbot": "^3.6.5", "jose": "^5.4.0", + "json-stable-stringify": "^1.3.0", "jsonpointer": "^5.0.1", "lodash.omit": "^4.5.0", "lucide-react": "^0.229.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df194c6295a..3ac9c6940e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,6 +620,9 @@ importers: jose: specifier: ^5.4.0 version: 5.4.0 + json-stable-stringify: + specifier: ^1.3.0 + version: 1.3.0 jsonpointer: specifier: ^5.0.1 version: 5.0.1 @@ -1116,7 +1119,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -11208,7 +11211,7 @@ packages: '@vercel/postgres@0.10.0': resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==} engines: {node: '>=18.14'} - deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide' + deprecated: '@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon''s SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide' '@vercel/sdk@1.19.1': resolution: {integrity: sha512-K4rmtUT6t1vX06tiY44ot8A7W1FKN7g/tMkE7yZghCgNQ8b30SzljBd4ni8RNp2pJzM/HrZmphRDeIArO7oZuw==} @@ -11875,6 +11878,7 @@ packages: basic-ftp@5.0.3: resolution: {integrity: sha512-QHX8HLlncOLpy54mh+k/sWIFd0ThmRqwe9ZjELybGZK+tZ8rUb9VO0saKJUROTbE+KhzDUT7xziGpGrW8Kmd+g==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -14314,25 +14318,29 @@ packages: glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.3.4: resolution: {integrity: sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.0: resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} @@ -17350,6 +17358,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true preferred-pm@3.0.3: @@ -19047,21 +19056,22 @@ packages: tar@6.1.13: resolution: {integrity: sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.5.6: resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -39412,7 +39422,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39449,8 +39459,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3 - socket.io-client: 4.7.3 + socket.io: 4.7.3(bufferutil@4.0.9) + socket.io-client: 4.7.3(bufferutil@4.0.9) sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -40677,7 +40687,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3: + socket.io-client@4.7.3(bufferutil@4.0.9): dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40706,7 +40716,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3: + socket.io@4.7.3(bufferutil@4.0.9): dependencies: accepts: 1.3.8 base64id: 2.0.0 From 0be8aa81e33aa164abe50ff49dd9019c2c60a191 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:23:36 +0100 Subject: [PATCH 22/40] feat: add worker group dropdown and confirmation dialog with diff view --- .../webapp/app/routes/admin.feature-flags.tsx | 288 +++++++++++++++--- 1 file changed, 251 insertions(+), 37 deletions(-) diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 29d32789298..749de95a109 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -14,8 +14,23 @@ import { import type { FlagControlType } from "~/v3/featureFlags.server"; import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogDescription, + DialogFooter, +} from "~/components/primitives/Dialog"; import { cn } from "~/utils/cn"; -import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "~/components/admin/FlagControls"; +import { + UNSET_VALUE, + BooleanControl, + EnumControl, + StringControl, +} from "~/components/admin/FlagControls"; +import { Select, SelectItem } from "~/components/primitives/Select"; + +type WorkerGroup = { id: string; name: string }; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUser(request); @@ -23,10 +38,16 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return redirect("/"); } - const globalFlags = await getGlobalFlags(); + const [globalFlags, workerGroups] = await Promise.all([ + getGlobalFlags(), + prisma.workerInstanceGroup.findMany({ + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + ]); const controlTypes = getAllFlagControlTypes(); - return typedjson({ globalFlags, controlTypes }); + return typedjson({ globalFlags, controlTypes, workerGroups }); }; export const action = async ({ request }: ActionFunctionArgs) => { @@ -41,11 +62,9 @@ export const action = async ({ request }: ActionFunctionArgs) => { const controlTypes = getAllFlagControlTypes(); const catalogKeys = Object.keys(controlTypes); - // For each catalog key: if value is present in newFlags, upsert it. If absent, delete the row. for (const key of catalogKeys) { if (key in newFlags) { const value = newFlags[key]; - // Validate the value against its schema const partial = { [key]: value }; const result = validatePartialFeatureFlags(partial); if (result.success) { @@ -56,7 +75,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); } } else { - // Unset - delete the row if it exists await prisma.featureFlag.deleteMany({ where: { key } }); } } @@ -65,14 +83,14 @@ export const action = async ({ request }: ActionFunctionArgs) => { }; export default function AdminFeatureFlagsRoute() { - const { globalFlags, controlTypes } = useTypedLoaderData(); + const { globalFlags, controlTypes, workerGroups } = useTypedLoaderData(); const saveFetcher = useFetcher<{ success?: boolean; error?: string }>(); const [values, setValues] = useState>({}); const [initialValues, setInitialValues] = useState>({}); const [saveError, setSaveError] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); - // Sync loader data into local state useEffect(() => { const loaded = (globalFlags ?? {}) as Record; setValues({ ...loaded }); @@ -82,8 +100,8 @@ export default function AdminFeatureFlagsRoute() { useEffect(() => { if (saveFetcher.data?.success) { setSaveError(null); - // Update initial to match saved state setInitialValues({ ...values }); + setConfirmOpen(false); } else if (saveFetcher.data?.error) { setSaveError(saveFetcher.data.error); } @@ -111,6 +129,15 @@ export default function AdminFeatureFlagsRoute() { }); }; + const workerGroupMap = new Map( + (workerGroups as WorkerGroup[]).map((wg) => [wg.id, wg.name]) + ); + + const resolveWorkerGroupDisplay = (id: string) => { + const name = workerGroupMap.get(id); + return name ? `${name} (${id.slice(0, 8)}...)` : id; + }; + const sortedFlagKeys = Object.keys(controlTypes as Record).sort(); return ( @@ -126,6 +153,8 @@ export default function AdminFeatureFlagsRoute() { const control = (controlTypes as Record)[key]; const isSet = key in values; + const isWorkerGroup = key === "defaultWorkerInstanceGroupId"; + return (
- {key} + {isWorkerGroup ? "defaultWorkerInstanceGroup" : key}
- {isSet ? `value: ${String(values[key])}` : "not set"} + {isSet + ? isWorkerGroup + ? resolveWorkerGroupDisplay(values[key] as string) + : `value: ${String(values[key])}` + : "not set"}
@@ -159,18 +192,10 @@ export default function AdminFeatureFlagsRoute() { unset - {control.type === "boolean" && ( - setFlagValue(key, val)} - dimmed={!isSet} - /> - )} - - {control.type === "enum" && ( - { if (val === UNSET_VALUE) { unsetFlag(key); @@ -180,20 +205,45 @@ export default function AdminFeatureFlagsRoute() { }} dimmed={!isSet} /> - )} + ) : ( + <> + {control.type === "boolean" && ( + setFlagValue(key, val)} + dimmed={!isSet} + /> + )} - {control.type === "string" && ( - { - if (val === "") { - unsetFlag(key); - } else { - setFlagValue(key, val); - } - }} - dimmed={!isSet} - /> + {control.type === "enum" && ( + { + if (val === UNSET_VALUE) { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isSet} + /> + )} + + {control.type === "string" && ( + { + if (val === "") { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isSet} + /> + )} + )}
@@ -206,13 +256,177 @@ export default function AdminFeatureFlagsRoute() {
+ + } + workerGroupMap={workerGroupMap} + onConfirm={handleSave} + isSaving={isSaving} + /> ); } + +// --- Worker Group Select --- + +function WorkerGroupControl({ + value, + workerGroups, + onChange, + dimmed, +}: { + value: string | undefined; + workerGroups: WorkerGroup[]; + onChange: (val: string) => void; + dimmed: boolean; +}) { + const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)]; + + return ( + + ); +} + +// --- Confirmation Dialog with Diff --- + +function ConfirmDialog({ + open, + onOpenChange, + initialValues, + newValues, + controlTypes, + workerGroupMap, + onConfirm, + isSaving, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + initialValues: Record; + newValues: Record; + controlTypes: Record; + workerGroupMap: Map; + onConfirm: () => void; + isSaving: boolean; +}) { + const allKeys = Object.keys(controlTypes).sort(); + + const changes = allKeys.flatMap((key) => { + const wasSet = key in initialValues; + const isSet = key in newValues; + const oldVal = initialValues[key]; + const newVal = newValues[key]; + + if (!wasSet && !isSet) return []; + if (wasSet && isSet && stableStringify(oldVal) === stableStringify(newVal)) return []; + + const displayKey = + key === "defaultWorkerInstanceGroupId" ? "defaultWorkerInstanceGroup" : key; + + const formatVal = (val: unknown) => { + if (key === "defaultWorkerInstanceGroupId" && typeof val === "string") { + const name = workerGroupMap.get(val); + return name ? `${name} (${val.slice(0, 8)}...)` : String(val); + } + return String(val); + }; + + if (!wasSet && isSet) { + return [{ key: displayKey, type: "added" as const, newVal: formatVal(newVal) }]; + } + if (wasSet && !isSet) { + return [{ key: displayKey, type: "removed" as const, oldVal: formatVal(oldVal) }]; + } + return [ + { + key: displayKey, + type: "changed" as const, + oldVal: formatVal(oldVal), + newVal: formatVal(newVal), + }, + ]; + }); + + return ( + + + Confirm Feature Flag Changes + + These changes affect all organizations globally. Please review carefully. + + +
+ {changes.length === 0 ? ( +

No changes to apply.

+ ) : ( + changes.map((change) => ( +
+
{change.key}
+ {change.type === "added" && ( +
+ {change.newVal}
+ )} + {change.type === "removed" && ( +
- {change.oldVal} (unset)
+ )} + {change.type === "changed" && ( + <> +
- {change.oldVal}
+
+ {change.newVal}
+ + )} +
+ )) + )} +
+ + + + + +
+
+ ); +} From 601e0096092de16bdb45a9239608d35dc0d5089c Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:23:56 +0100 Subject: [PATCH 23/40] fix: return validation error instead of throwing to populate fetcher.data --- apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts index 83154fb3ae7..de74cf3da66 100644 --- a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts @@ -74,7 +74,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } else { const validationResult = validatePartialFeatureFlags(body as Record); if (!validationResult.success) { - throw json( + return json( { error: "Invalid feature flags", details: validationResult.error.issues }, { status: 400 } ); From 38138aabfca6a965ddbc933aff1b8c64918cac1f Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:27:48 +0100 Subject: [PATCH 24/40] feat: add discard button to reset unsaved changes --- apps/webapp/app/routes/admin.feature-flags.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 749de95a109..4b2accd613a 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -254,6 +254,14 @@ export default function AdminFeatureFlagsRoute() { {saveError && {saveError}}
+ {isDirty && ( + + )} diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index f2a27ec22cb..0de2675f3d3 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -295,7 +295,7 @@ export default function AdminFeatureFlagsRoute() { onClick={() => setConfirmOpen(true)} disabled={!isDirty || isSaving} > - Review Changes + Review changes
@@ -423,7 +423,7 @@ function ConfirmDialog({ return ( - Confirm Feature Flag Changes + Confirm feature flag changes These changes affect all organizations globally. Please review carefully. @@ -464,7 +464,7 @@ function ConfirmDialog({ onClick={onConfirm} disabled={isSaving || changes.length === 0} > - {isSaving ? "Saving..." : "Apply Changes"} + {isSaving ? "Saving..." : "Apply changes"} From 35add192923ea7deb9327768c096a8a08621949f Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:27:23 +0100 Subject: [PATCH 32/40] fix: reduce spacing above change list in confirm dialog --- apps/webapp/app/routes/admin.feature-flags.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 0de2675f3d3..f126dfa2b8e 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -428,7 +428,7 @@ function ConfirmDialog({ These changes affect all organizations globally. Please review carefully. -
+
{changes.length === 0 ? (

No changes to apply.

) : ( From a8e2922f66042241bafa80ed067b0e64081be18b Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:29:45 +0100 Subject: [PATCH 33/40] fix: use sentence case for dialog header --- apps/webapp/app/components/admin/FeatureFlagsDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 7eccd504631..5b3726246c9 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -112,7 +112,7 @@ export function FeatureFlagsDialog({ return ( - Feature Flags - {orgTitle} + Feature flags - {orgTitle} Org-level overrides. Unset flags inherit from global defaults. From fd422eaf10b3ec9cfc5d9b6f37a238c602665d3e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:43:09 +0100 Subject: [PATCH 34/40] refactor: cleaner feature flag server module split --- .../components/admin/FeatureFlagsDialog.tsx | 17 ++--- .../OrganizationsPresenter.server.ts | 3 +- .../presenters/v3/RegionsPresenter.server.ts | 3 +- .../route.tsx | 2 +- .../admin.api.orgs.$orgId.feature-flags.ts | 12 ++-- .../app/routes/admin.api.v1.feature-flags.ts | 3 +- ...i.v1.orgs.$organizationId.feature-flags.ts | 2 +- .../webapp/app/routes/admin.feature-flags.tsx | 21 +++--- .../route.tsx | 2 +- .../runsRepository/runsRepository.server.ts | 3 +- apps/webapp/app/v3/canAccessAi.server.ts | 3 +- .../webapp/app/v3/canAccessAiModels.server.ts | 3 +- apps/webapp/app/v3/canAccessQuery.server.ts | 3 +- .../app/v3/eventRepository/index.server.ts | 3 +- apps/webapp/app/v3/featureFlags.server.ts | 71 ++----------------- apps/webapp/app/v3/featureFlags.ts | 64 +++++++++++++++++ .../worker/workerGroupService.server.ts | 3 +- 17 files changed, 109 insertions(+), 109 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 5b3726246c9..5ac2da580f3 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -11,8 +11,7 @@ import { import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { cn } from "~/utils/cn"; -import { FEATURE_FLAG } from "~/v3/featureFlags"; -import type { FlagControlType } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG, type FlagControlType } from "~/v3/featureFlags"; import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "./FlagControls"; const HIDDEN_FLAGS = [FEATURE_FLAG.defaultWorkerInstanceGroupId]; @@ -126,8 +125,7 @@ export function FeatureFlagsDialog({ const control = data.controlTypes[key]; const isOverridden = key in overrides; const globalValue = data.globalFlags[key as keyof typeof data.globalFlags]; - const globalDisplay = - globalValue !== undefined ? String(globalValue) : "unset"; + const globalDisplay = globalValue !== undefined ? String(globalValue) : "unset"; return (
)} - {saveError && ( - {saveError} - )} + {saveError && {saveError}} - @@ -235,4 +227,3 @@ export function FeatureFlagsDialog({
); } - diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index f99164be5ae..5eab427a150 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -10,7 +10,8 @@ import { } from "./SelectBestEnvironmentPresenter.server"; import { sortEnvironments } from "~/utils/environmentSort"; import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar"; -import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server"; +import { flags } from "~/v3/featureFlags.server"; +import { validatePartialFeatureFlags } from "~/v3/featureFlags"; export class OrganizationsPresenter { #prismaClient: PrismaClient; diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 7a35fb6fb9b..f72b8d2fc53 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -1,6 +1,7 @@ import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; -import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { makeFlag } from "~/v3/featureFlags.server"; import { BasePresenter } from "./basePresenter.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index 0c7f75fe262..80a5c6ef232 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -37,7 +37,7 @@ import { ResizablePanelGroup, } from "~/components/primitives/Resizable"; import { Button } from "~/components/primitives/Buttons"; -import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags"; // Valid log levels for filtering const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]; diff --git a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts index cac5348fc0c..a2e69f5620b 100644 --- a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts @@ -4,11 +4,8 @@ import { Prisma } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUser } from "~/services/session.server"; -import { - flags as getGlobalFlags, - validatePartialFeatureFlags, - getAllFlagControlTypes, -} from "~/v3/featureFlags.server"; +import { flags as getGlobalFlags } from "~/v3/featureFlags.server"; +import { validatePartialFeatureFlags, getAllFlagControlTypes } from "~/v3/featureFlags"; const ParamsSchema = z.object({ orgId: z.string(), @@ -75,7 +72,10 @@ export async function action({ request, params }: ActionFunctionArgs) { let featureFlags: typeof Prisma.JsonNull | Record; - if (body === null || (typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0)) { + if ( + body === null || + (typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0) + ) { featureFlags = Prisma.JsonNull; } else { const validationResult = validatePartialFeatureFlags(body as Record); diff --git a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts index d0e1dd6b274..60c835e0542 100644 --- a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts @@ -1,7 +1,8 @@ import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { prisma } from "~/db.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { makeSetMultipleFlags, validatePartialFeatureFlags } from "~/v3/featureFlags.server"; +import { makeSetMultipleFlags } from "~/v3/featureFlags.server"; +import { validatePartialFeatureFlags } from "~/v3/featureFlags"; export async function action({ request }: ActionFunctionArgs) { // Next authenticate the request diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts index 96bf340fe0e..f962b35db6b 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts @@ -2,7 +2,7 @@ import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server- import { z } from "zod"; import { prisma } from "~/db.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { validatePartialFeatureFlags } from "~/v3/featureFlags.server"; +import { validatePartialFeatureFlags } from "~/v3/featureFlags"; const ParamsSchema = z.object({ organizationId: z.string(), diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index f126dfa2b8e..4b7cb5dafd2 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -7,13 +7,13 @@ import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUser } from "~/services/session.server"; -import { FEATURE_FLAG } from "~/v3/featureFlags"; import { - flags as getGlobalFlags, + FEATURE_FLAG, + type FlagControlType, getAllFlagControlTypes, validatePartialFeatureFlags, -} from "~/v3/featureFlags.server"; -import type { FlagControlType } from "~/v3/featureFlags.server"; +} from "~/v3/featureFlags"; +import { flags as getGlobalFlags } from "~/v3/featureFlags.server"; import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { @@ -157,9 +157,7 @@ export default function AdminFeatureFlagsRoute() { }); }; - const workerGroupMap = new Map( - (workerGroups as WorkerGroup[]).map((wg) => [wg.id, wg.name]) - ); + const workerGroupMap = new Map((workerGroups as WorkerGroup[]).map((wg) => [wg.id, wg.name])); const resolveWorkerGroupDisplay = (id: string) => { const name = workerGroupMap.get(id); @@ -172,8 +170,8 @@ export default function AdminFeatureFlagsRoute() {

- Global defaults for all organizations. Org-level overrides take precedence. - When not set, each consumer uses its own default. + Global defaults for all organizations. Org-level overrides take precedence. When not set, + each consumer uses its own default.

@@ -283,10 +281,7 @@ export default function AdminFeatureFlagsRoute() {
{isDirty && ( - )} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.can-view-logs-page/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.can-view-logs-page/route.tsx index 2d205d90c10..f55307c7c34 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.can-view-logs-page/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.can-view-logs-page/route.tsx @@ -2,7 +2,7 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson } from "remix-typedjson"; import { requireUser } from "~/services/session.server"; import { prisma } from "~/db.server"; -import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags"; import { OrganizationParamsSchema } from "~/utils/pathBuilder"; async function hasLogsPageAccess( diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 99e5e00ce6e..0bcc73eab6e 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -8,7 +8,8 @@ import parseDuration from "parse-duration"; import { z } from "zod"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { type PrismaClient, type PrismaClientOrTransaction } from "~/db.server"; -import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { makeFlag } from "~/v3/featureFlags.server"; import { startActiveSpan } from "~/v3/tracer.server"; import { logger } from "../logger.server"; import { ClickHouseRunsRepository } from "./clickhouseRunsRepository.server"; diff --git a/apps/webapp/app/v3/canAccessAi.server.ts b/apps/webapp/app/v3/canAccessAi.server.ts index d7957c4dcc7..61e9258e0b7 100644 --- a/apps/webapp/app/v3/canAccessAi.server.ts +++ b/apps/webapp/app/v3/canAccessAi.server.ts @@ -1,6 +1,7 @@ import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { makeFlag } from "~/v3/featureFlags.server"; export async function canAccessAi(options: { userId: string; diff --git a/apps/webapp/app/v3/canAccessAiModels.server.ts b/apps/webapp/app/v3/canAccessAiModels.server.ts index a3a489ce881..d1988ba7f89 100644 --- a/apps/webapp/app/v3/canAccessAiModels.server.ts +++ b/apps/webapp/app/v3/canAccessAiModels.server.ts @@ -1,6 +1,7 @@ import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { makeFlag } from "~/v3/featureFlags.server"; export async function canAccessAiModels(options: { userId: string; diff --git a/apps/webapp/app/v3/canAccessQuery.server.ts b/apps/webapp/app/v3/canAccessQuery.server.ts index 87a248725b5..13b7602d937 100644 --- a/apps/webapp/app/v3/canAccessQuery.server.ts +++ b/apps/webapp/app/v3/canAccessQuery.server.ts @@ -1,6 +1,7 @@ import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { makeFlag } from "~/v3/featureFlags.server"; export async function canAccessQuery(options: { userId: string; diff --git a/apps/webapp/app/v3/eventRepository/index.server.ts b/apps/webapp/app/v3/eventRepository/index.server.ts index 2f457e23593..70ea6440321 100644 --- a/apps/webapp/app/v3/eventRepository/index.server.ts +++ b/apps/webapp/app/v3/eventRepository/index.server.ts @@ -7,7 +7,8 @@ import { import { IEventRepository, TraceEventOptions } from "./eventRepository.types"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; -import { FEATURE_FLAG, flag } from "../featureFlags.server"; +import { FEATURE_FLAG } from "../featureFlags"; +import { flag } from "../featureFlags.server"; import { getTaskEventStore } from "../taskEventStore.server"; export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository { diff --git a/apps/webapp/app/v3/featureFlags.server.ts b/apps/webapp/app/v3/featureFlags.server.ts index bb4f1cb65e2..cdf483fa651 100644 --- a/apps/webapp/app/v3/featureFlags.server.ts +++ b/apps/webapp/app/v3/featureFlags.server.ts @@ -1,19 +1,10 @@ -import { z } from "zod"; +import { type z } from "zod"; import { prisma, type PrismaClientOrTransaction } from "~/db.server"; -export { FEATURE_FLAG } from "~/v3/featureFlags"; -import { FEATURE_FLAG } from "~/v3/featureFlags"; - -const FeatureFlagCatalog = { - [FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(), - [FEATURE_FLAG.runsListRepository]: z.enum(["clickhouse", "postgres"]), - [FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]), - [FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(), - [FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(), - [FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(), - [FEATURE_FLAG.hasAiModelsAccess]: z.coerce.boolean(), -}; - -type FeatureFlagKey = keyof typeof FeatureFlagCatalog; +import { + type FeatureFlagKey, + FeatureFlagCatalog, + FeatureFlagCatalogSchema, +} from "~/v3/featureFlags"; export type FlagsOptions = { key: T; @@ -132,56 +123,6 @@ export const flag = makeFlag(); export const flags = makeFlags(); export const setFlag = makeSetFlag(); -// Create a Zod schema from the existing catalog -export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog); -export type FeatureFlagCatalog = z.infer; - -// Utility function to validate a feature flag value -export function validateFeatureFlagValue( - key: T, - value: unknown -): z.SafeParseReturnType> { - return FeatureFlagCatalog[key].safeParse(value); -} - -// Utility function to validate all feature flags at once -export function validateAllFeatureFlags(values: Record) { - return FeatureFlagCatalogSchema.safeParse(values); -} - -// Utility function to validate partial feature flags (all keys optional) -export function validatePartialFeatureFlags(values: Record) { - return FeatureFlagCatalogSchema.partial().safeParse(values); -} - -// Utility types for catalog-driven UI rendering -export type FlagControlType = - | { type: "boolean" } - | { type: "enum"; options: string[] } - | { type: "string" }; - -export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType { - const typeName = schema._def.typeName; - - if (typeName === "ZodBoolean") { - return { type: "boolean" }; - } - - if (typeName === "ZodEnum") { - return { type: "enum", options: schema._def.values as string[] }; - } - - return { type: "string" }; -} - -export function getAllFlagControlTypes(): Record { - const result: Record = {}; - for (const [key, schema] of Object.entries(FeatureFlagCatalog)) { - result[key] = getFlagControlType(schema); - } - return result; -} - // Utility function to set multiple feature flags at once export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma) { return async function setMultipleFlags( diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 6bc379eb353..7f83555db9e 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export const FEATURE_FLAG = { defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId", runsListRepository: "runsListRepository", @@ -7,3 +9,65 @@ export const FEATURE_FLAG = { hasAiAccess: "hasAiAccess", hasAiModelsAccess: "hasAiModelsAccess", } as const; + +export const FeatureFlagCatalog = { + [FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(), + [FEATURE_FLAG.runsListRepository]: z.enum(["clickhouse", "postgres"]), + [FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]), + [FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(), + [FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(), + [FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(), + [FEATURE_FLAG.hasAiModelsAccess]: z.coerce.boolean(), +}; + +export type FeatureFlagKey = keyof typeof FeatureFlagCatalog; + +// Create a Zod schema from the existing catalog +export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog); +export type FeatureFlagCatalog = z.infer; + +// Utility function to validate a feature flag value +export function validateFeatureFlagValue( + key: T, + value: unknown +): z.SafeParseReturnType> { + return FeatureFlagCatalog[key].safeParse(value); +} + +// Utility function to validate all feature flags at once +export function validateAllFeatureFlags(values: Record) { + return FeatureFlagCatalogSchema.safeParse(values); +} + +// Utility function to validate partial feature flags (all keys optional) +export function validatePartialFeatureFlags(values: Record) { + return FeatureFlagCatalogSchema.partial().safeParse(values); +} + +// Utility types for catalog-driven UI rendering +export type FlagControlType = + | { type: "boolean" } + | { type: "enum"; options: string[] } + | { type: "string" }; + +export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType { + const typeName = schema._def.typeName; + + if (typeName === "ZodBoolean") { + return { type: "boolean" }; + } + + if (typeName === "ZodEnum") { + return { type: "enum", options: schema._def.values as string[] }; + } + + return { type: "string" }; +} + +export function getAllFlagControlTypes(): Record { + const result: Record = {}; + for (const [key, schema] of Object.entries(FeatureFlagCatalog)) { + result[key] = getFlagControlType(schema); + } + return result; +} diff --git a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts index 936f8bbd48e..6a900a16fd7 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts @@ -2,7 +2,8 @@ import { WorkerInstanceGroup, WorkerInstanceGroupType } from "@trigger.dev/datab import { WithRunEngine } from "../baseService.server"; import { WorkerGroupTokenService } from "./workerGroupTokenService.server"; import { logger } from "~/services/logger.server"; -import { FEATURE_FLAG, makeFlag, makeSetFlag } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { makeFlag, makeSetFlag } from "~/v3/featureFlags.server"; export class WorkerGroupService extends WithRunEngine { private readonly defaultNamePrefix = "worker_group"; From f0f8099a043de86c5cba233fc67327bb2067157e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:53:36 +0100 Subject: [PATCH 35/40] refactor: rename feature flags route to v2, match v1 param naming --- .../app/components/admin/FeatureFlagsDialog.tsx | 4 ++-- ....api.v2.orgs.$organizationId.feature-flags.ts} | 15 ++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) rename apps/webapp/app/routes/{admin.api.orgs.$orgId.feature-flags.ts => admin.api.v2.orgs.$organizationId.feature-flags.ts} (84%) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 5ac2da580f3..4f924b3f998 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -51,7 +51,7 @@ export function FeatureFlagsDialog({ useEffect(() => { if (open && orgId) { setSaveError(null); - loadFetcher.load(`/admin/api/orgs/${orgId}/feature-flags`); + loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`); } }, [open, orgId]); @@ -90,7 +90,7 @@ export function FeatureFlagsDialog({ const body = Object.keys(overrides).length === 0 ? null : overrides; saveFetcher.submit(JSON.stringify(body), { method: "POST", - action: `/admin/api/orgs/${orgId}/feature-flags`, + action: `/admin/api/v2/orgs/${orgId}/feature-flags`, encType: "application/json", }); }; diff --git a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts b/apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts similarity index 84% rename from apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts rename to apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts index a2e69f5620b..34edf0a63c8 100644 --- a/apps/webapp/app/routes/admin.api.orgs.$orgId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts @@ -7,8 +7,13 @@ import { requireUser } from "~/services/session.server"; import { flags as getGlobalFlags } from "~/v3/featureFlags.server"; import { validatePartialFeatureFlags, getAllFlagControlTypes } from "~/v3/featureFlags"; +// Session-auth route for the admin feature flags dialog. +// Uses replace semantics: the action writes the full flag set (or null to clear). +// Compare with v1 (admin.api.v1.orgs.$organizationId.feature-flags.ts) which +// uses PAT auth and merge semantics for programmatic use. + const ParamsSchema = z.object({ - orgId: z.string(), + organizationId: z.string(), }); export async function loader({ request, params }: LoaderFunctionArgs) { @@ -17,11 +22,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw new Response("Unauthorized", { status: 403 }); } - const { orgId } = ParamsSchema.parse(params); + const { organizationId } = ParamsSchema.parse(params); const [organization, globalFlags] = await Promise.all([ prisma.organization.findFirst({ - where: { id: orgId }, + where: { id: organizationId }, select: { id: true, title: true, @@ -61,7 +66,7 @@ export async function action({ request, params }: ActionFunctionArgs) { throw new Response("Unauthorized", { status: 403 }); } - const { orgId } = ParamsSchema.parse(params); + const { organizationId } = ParamsSchema.parse(params); let body: unknown; try { @@ -90,7 +95,7 @@ export async function action({ request, params }: ActionFunctionArgs) { try { await prisma.organization.update({ - where: { id: orgId }, + where: { id: organizationId }, data: { featureFlags: featureFlags as Prisma.InputJsonValue }, }); } catch (e) { From 6af67a938a703ae9607957c7911d2575aa8742ae Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:55:17 +0100 Subject: [PATCH 36/40] chore: update server-changes to include global flags tab --- .server-changes/admin-feature-flags-dialog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.server-changes/admin-feature-flags-dialog.md b/.server-changes/admin-feature-flags-dialog.md index d771b2b57ca..2517e21a3b8 100644 --- a/.server-changes/admin-feature-flags-dialog.md +++ b/.server-changes/admin-feature-flags-dialog.md @@ -3,4 +3,4 @@ area: webapp type: feature --- -Add admin UI dialog for viewing and editing org-level feature flag overrides. +Add admin UI for viewing and editing feature flags (org-level overrides and global defaults). From 67dd8b861d45d39f6864e8ef25bce3fe2ae799c3 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:59:30 +0100 Subject: [PATCH 37/40] fix: replace findUnique with findFirst in v1 org feature flags route --- .../admin.api.v1.orgs.$organizationId.feature-flags.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts index f962b35db6b..bb0671355bd 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts @@ -15,7 +15,7 @@ async function authenticateAdmin(request: Request) { return { error: json({ error: "Invalid or Missing API key" }, { status: 401 }) }; } - const user = await prisma.user.findUnique({ + const user = await prisma.user.findFirst({ where: { id: authenticationResult.userId, }, @@ -41,7 +41,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { organizationId } = ParamsSchema.parse(params); - const organization = await prisma.organization.findUnique({ + const organization = await prisma.organization.findFirst({ where: { id: organizationId, }, @@ -78,7 +78,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const { organizationId } = ParamsSchema.parse(params); - const organization = await prisma.organization.findUnique({ + const organization = await prisma.organization.findFirst({ where: { id: organizationId, }, From 13d6813a56b5ec65fa32361134425b65b6ac9b68 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:15:27 +0100 Subject: [PATCH 38/40] fix: address review feedback - findFirst, cross-org state reset, stale closure --- apps/webapp/app/components/admin/FeatureFlagsDialog.tsx | 2 ++ apps/webapp/app/routes/admin.api.v1.feature-flags.ts | 2 +- apps/webapp/app/routes/admin.feature-flags.tsx | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 4f924b3f998..840ebefa629 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -51,6 +51,8 @@ export function FeatureFlagsDialog({ useEffect(() => { if (open && orgId) { setSaveError(null); + setOverrides({}); + setInitialOverrides({}); loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`); } }, [open, orgId]); diff --git a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts index 60c835e0542..b10c983b14d 100644 --- a/apps/webapp/app/routes/admin.api.v1.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts @@ -12,7 +12,7 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); } - const user = await prisma.user.findUnique({ + const user = await prisma.user.findFirst({ where: { id: authenticationResult.userId, }, diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 4b7cb5dafd2..ad42b0b944f 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -128,7 +128,6 @@ export default function AdminFeatureFlagsRoute() { useEffect(() => { if (saveFetcher.data?.success) { setSaveError(null); - setInitialValues({ ...values }); setConfirmOpen(false); } else if (saveFetcher.data?.error) { setSaveError(saveFetcher.data.error); From e17007e695430eca9d10f6785ff686ea6d5d94bc Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:30:19 +0100 Subject: [PATCH 39/40] feat: add locked flags with read-only UI, unlock checkbox for non-cloud envs --- .../components/admin/FeatureFlagsDialog.tsx | 97 ++++++-- .../app/components/admin/FlagControls.tsx | 42 ++++ ...i.v2.orgs.$organizationId.feature-flags.ts | 27 ++- .../webapp/app/routes/admin.feature-flags.tsx | 223 ++++++++++++------ apps/webapp/app/v3/featureFlags.ts | 16 ++ 5 files changed, 318 insertions(+), 87 deletions(-) diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 840ebefa629..df8669d36dd 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -10,17 +10,27 @@ import { } 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, type FlagControlType } from "~/v3/featureFlags"; -import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "./FlagControls"; - -const HIDDEN_FLAGS = [FEATURE_FLAG.defaultWorkerInstanceGroupId]; +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; globalFlags: Record; controlTypes: Record; + workerGroupName?: string; + workerGroups?: WorkerGroup[]; + isManagedCloud?: boolean; }; type ActionData = { @@ -47,6 +57,9 @@ export function FeatureFlagsDialog({ const [overrides, setOverrides] = useState>({}); const [initialOverrides, setInitialOverrides] = useState>({}); const [saveError, setSaveError] = useState(null); + const [unlocked, setUnlocked] = useState(false); + + const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key); useEffect(() => { if (open && orgId) { @@ -104,11 +117,7 @@ export function FeatureFlagsDialog({ const jsonPreview = Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2); - const sortedFlagKeys = data - ? Object.keys(data.controlTypes) - .filter((key) => !HIDDEN_FLAGS.includes(key)) - .sort() - : []; + const sortedFlagKeys = data ? Object.keys(data.controlTypes).sort() : []; return ( @@ -118,6 +127,23 @@ export function FeatureFlagsDialog({ Org-level overrides. Unset flags inherit from global defaults. + {data && ( +
+ +
+ )} +
{isLoading ? (
Loading flags...
@@ -125,9 +151,33 @@ export function FeatureFlagsDialog({
{sortedFlagKeys.map((key) => { const control = data.controlTypes[key]; - const isOverridden = key in overrides; + const locked = isLocked(key); const globalValue = data.globalFlags[key as keyof typeof data.globalFlags]; - const globalDisplay = globalValue !== undefined ? String(globalValue) : "unset"; + 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 ( +
+
+
{key}
+
global: {globalDisplay}
+
+ +
+ ); + } + + const isOverridden = key in overrides; return (
- {control.type === "boolean" && ( + {isWorkerGroup && data.workerGroups ? ( + { + if (val === UNSET_VALUE) { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isOverridden} + /> + ) : control.type === "boolean" ? ( setFlagValue(key, val)} dimmed={!isOverridden} /> - )} - - {control.type === "enum" && ( + ) : control.type === "enum" ? ( - )} - - {control.type === "string" && ( + ) : control.type === "string" ? ( { @@ -195,7 +254,7 @@ export function FeatureFlagsDialog({ }} dimmed={!isOverridden} /> - )} + ) : null}
); diff --git a/apps/webapp/app/components/admin/FlagControls.tsx b/apps/webapp/app/components/admin/FlagControls.tsx index 6a325df838a..b08f925dd90 100644 --- a/apps/webapp/app/components/admin/FlagControls.tsx +++ b/apps/webapp/app/components/admin/FlagControls.tsx @@ -57,6 +57,48 @@ export function EnumControl({ ); } +export type WorkerGroup = { id: string; name: string }; + +export function WorkerGroupControl({ + value, + workerGroups, + onChange, + dimmed, +}: { + value: string | undefined; + workerGroups: WorkerGroup[]; + onChange: (val: string) => void; + dimmed: boolean; +}) { + const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)]; + + return ( + + ); +} + export function StringControl({ value, onChange, diff --git a/apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts b/apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts index 34edf0a63c8..6081febb526 100644 --- a/apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts +++ b/apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts @@ -5,7 +5,8 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUser } from "~/services/session.server"; import { flags as getGlobalFlags } from "~/v3/featureFlags.server"; -import { validatePartialFeatureFlags, getAllFlagControlTypes } from "~/v3/featureFlags"; +import { FEATURE_FLAG, validatePartialFeatureFlags, getAllFlagControlTypes } from "~/v3/featureFlags"; +import { featuresForRequest } from "~/features.server"; // Session-auth route for the admin feature flags dialog. // 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) { const { organizationId } = ParamsSchema.parse(params); - const [organization, globalFlags] = await Promise.all([ + const [organization, globalFlags, workerGroups] = await Promise.all([ prisma.organization.findFirst({ where: { id: organizationId }, select: { @@ -35,6 +36,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, }), getGlobalFlags(), + prisma.workerInstanceGroup.findMany({ + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), ]); if (!organization) { @@ -48,6 +53,21 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {}; const controlTypes = getAllFlagControlTypes(); + // Resolve worker group name for display + const workerGroupId = (globalFlags as Record)?.[ + FEATURE_FLAG.defaultWorkerInstanceGroupId + ]; + let workerGroupName: string | undefined; + if (typeof workerGroupId === "string") { + const wg = await prisma.workerInstanceGroup.findFirst({ + where: { id: workerGroupId }, + select: { name: true }, + }); + workerGroupName = wg?.name; + } + + const { isManagedCloud } = featuresForRequest(request); + return json({ org: { id: organization.id, @@ -57,6 +77,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { orgFlags, globalFlags, controlTypes, + workerGroupName, + workerGroups, + isManagedCloud, }); } diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index ad42b0b944f..8d3841fcb2b 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -5,17 +5,22 @@ import stableStringify from "json-stable-stringify"; import { json } from "@remix-run/server-runtime"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; +import { LockClosedIcon } from "@heroicons/react/20/solid"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; import { requireUser } from "~/services/session.server"; import { FEATURE_FLAG, + GLOBAL_LOCKED_FLAGS, type FlagControlType, getAllFlagControlTypes, validatePartialFeatureFlags, } from "~/v3/featureFlags"; import { flags as getGlobalFlags } from "~/v3/featureFlags.server"; +import { featuresForRequest } from "~/features.server"; import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Dialog, DialogContent, @@ -29,10 +34,9 @@ import { BooleanControl, EnumControl, StringControl, + WorkerGroupControl, + type WorkerGroup, } from "~/components/admin/FlagControls"; -import { Select, SelectItem } from "~/components/primitives/Select"; - -type WorkerGroup = { id: string; name: string }; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUser(request); @@ -49,7 +53,31 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { ]); const controlTypes = getAllFlagControlTypes(); - return typedjson({ globalFlags, controlTypes, workerGroups }); + // Resolve env-based defaults for locked flags + const resolvedDefaults: Record = { + [FEATURE_FLAG.runsListRepository]: "clickhouse", + [FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE, + }; + + // Look up worker group name if the flag is set + const workerGroupId = (globalFlags as Record)?.[ + FEATURE_FLAG.defaultWorkerInstanceGroupId + ]; + const workerGroupName = + typeof workerGroupId === "string" + ? workerGroups.find((wg) => wg.id === workerGroupId)?.name + : undefined; + + const { isManagedCloud } = featuresForRequest(request); + + return typedjson({ + globalFlags, + controlTypes, + resolvedDefaults, + workerGroupName, + workerGroups, + isManagedCloud, + }); }; export const action = async ({ request }: ActionFunctionArgs) => { @@ -71,6 +99,21 @@ export const action = async ({ request }: ActionFunctionArgs) => { return json({ error: "Invalid payload" }, { status: 400 }); } + const { isManagedCloud } = featuresForRequest(request); + + // On managed cloud, reject if payload includes locked flags + if (isManagedCloud) { + const lockedInPayload = Object.keys(parsed.data.flags).filter((key) => + GLOBAL_LOCKED_FLAGS.includes(key) + ); + if (lockedInPayload.length > 0) { + return json( + { error: `Cannot modify locked flags: ${lockedInPayload.join(", ")}` }, + { status: 400 } + ); + } + } + const validationResult = validatePartialFeatureFlags(parsed.data.flags); if (!validationResult.success) { return json( @@ -81,12 +124,15 @@ export const action = async ({ request }: ActionFunctionArgs) => { const validatedFlags = validationResult.data as Record; const controlTypes = getAllFlagControlTypes(); - const catalogKeys = Object.keys(controlTypes); + const lockedKeys = isManagedCloud ? GLOBAL_LOCKED_FLAGS : []; + const editableKeys = Object.keys(controlTypes).filter( + (key) => !lockedKeys.includes(key) + ); const keysToDelete: string[] = []; const upsertOps: ReturnType[] = []; - for (const key of catalogKeys) { + for (const key of editableKeys) { if (key in validatedFlags) { upsertOps.push( prisma.featureFlag.upsert({ @@ -111,19 +157,36 @@ export const action = async ({ request }: ActionFunctionArgs) => { }; export default function AdminFeatureFlagsRoute() { - const { globalFlags, controlTypes, workerGroups } = useTypedLoaderData(); + const { + globalFlags, + controlTypes, + resolvedDefaults, + workerGroupName, + workerGroups, + isManagedCloud, + } = useTypedLoaderData(); const saveFetcher = useFetcher<{ success?: boolean; error?: string }>(); const [values, setValues] = useState>({}); const [initialValues, setInitialValues] = useState>({}); const [saveError, setSaveError] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); + const [unlocked, setUnlocked] = useState(false); + + const isLocked = (key: string) => !unlocked && GLOBAL_LOCKED_FLAGS.includes(key); useEffect(() => { const loaded = (globalFlags ?? {}) as Record; - setValues({ ...loaded }); - setInitialValues({ ...loaded }); - }, [globalFlags]); + // Only track editable flags in state + const editable: Record = {}; + for (const [key, value] of Object.entries(loaded)) { + if (!isLocked(key)) { + editable[key] = value; + } + } + setValues({ ...editable }); + setInitialValues({ ...editable }); + }, [globalFlags, unlocked]); useEffect(() => { if (saveFetcher.data?.success) { @@ -156,6 +219,8 @@ export default function AdminFeatureFlagsRoute() { }); }; + const sortedFlagKeys = Object.keys(controlTypes as Record).sort(); + const allFlags = (globalFlags ?? {}) as Record; const workerGroupMap = new Map((workerGroups as WorkerGroup[]).map((wg) => [wg.id, wg.name])); const resolveWorkerGroupDisplay = (id: string) => { @@ -163,8 +228,6 @@ export default function AdminFeatureFlagsRoute() { return name ? `${name} (${id.slice(0, 8)}...)` : id; }; - const sortedFlagKeys = Object.keys(controlTypes as Record).sort(); - return (
@@ -173,11 +236,39 @@ export default function AdminFeatureFlagsRoute() { each consumer uses its own default.

+
+ +
+
{sortedFlagKeys.map((key) => { const control = (controlTypes as Record)[key]; - const isSet = key in values; + const locked = isLocked(key); + + if (locked) { + return ( + )[key]} + workerGroupName={workerGroupName as string | undefined} + /> + ); + } + const isSet = key in values; const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId; return ( @@ -300,7 +391,7 @@ export default function AdminFeatureFlagsRoute() { initialValues={initialValues} newValues={values} controlTypes={controlTypes as Record} - workerGroupMap={workerGroupMap} + lockedKeys={unlocked ? [] : GLOBAL_LOCKED_FLAGS} onConfirm={handleSave} isSaving={isSaving} /> @@ -308,45 +399,54 @@ export default function AdminFeatureFlagsRoute() { ); } -// --- Worker Group Select --- +// --- Locked Flag Row --- -function WorkerGroupControl({ +function LockedFlagRow({ + flagKey, value, - workerGroups, - onChange, - dimmed, + resolvedDefault, + workerGroupName, }: { - value: string | undefined; - workerGroups: WorkerGroup[]; - onChange: (val: string) => void; - dimmed: boolean; + flagKey: string; + value: unknown; + resolvedDefault: string | undefined; + workerGroupName: string | undefined; }) { - const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)]; + const isSet = value !== undefined; + const isWorkerGroup = flagKey === FEATURE_FLAG.defaultWorkerInstanceGroupId; + + let displayValue: string; + if (isSet) { + if (isWorkerGroup && workerGroupName) { + displayValue = `${workerGroupName} (${String(value).slice(0, 8)}...)`; + } else { + displayValue = String(value); + } + } else if (resolvedDefault) { + displayValue = `${resolvedDefault} (from env)`; + } else { + displayValue = "not set (required)"; + } return ( - +
+
+ {isWorkerGroup ? "defaultWorkerInstanceGroup" : flagKey} +
+
{displayValue}
+
+ + +
); } @@ -358,7 +458,7 @@ function ConfirmDialog({ initialValues, newValues, controlTypes, - workerGroupMap, + lockedKeys, onConfirm, isSaving, }: { @@ -367,18 +467,20 @@ function ConfirmDialog({ initialValues: Record; newValues: Record; controlTypes: Record; - workerGroupMap: Map; + lockedKeys: readonly string[]; onConfirm: () => void; isSaving: boolean; }) { - const allKeys = Object.keys(controlTypes).sort(); + const editableKeys = Object.keys(controlTypes) + .filter((key) => !lockedKeys.includes(key)) + .sort(); type Change = | { key: string; type: "added"; newVal: string } | { key: string; type: "removed"; oldVal: string } | { key: string; type: "changed"; oldVal: string; newVal: string }; - const changes = allKeys.flatMap((key) => { + const changes = editableKeys.flatMap((key) => { const wasSet = key in initialValues; const isSet = key in newValues; const oldVal = initialValues[key]; @@ -387,29 +489,18 @@ function ConfirmDialog({ if (!wasSet && !isSet) return []; if (wasSet && isSet && stableStringify(oldVal) === stableStringify(newVal)) return []; - const displayKey = - key === FEATURE_FLAG.defaultWorkerInstanceGroupId ? "defaultWorkerInstanceGroup" : key; - - const formatVal = (val: unknown) => { - if (key === FEATURE_FLAG.defaultWorkerInstanceGroupId && typeof val === "string") { - const name = workerGroupMap.get(val); - return name ? `${name} (${val.slice(0, 8)}...)` : String(val); - } - return String(val); - }; - if (!wasSet && isSet) { - return [{ key: displayKey, type: "added" as const, newVal: formatVal(newVal) }]; + return [{ key, type: "added" as const, newVal: String(newVal) }]; } if (wasSet && !isSet) { - return [{ key: displayKey, type: "removed" as const, oldVal: formatVal(oldVal) }]; + return [{ key, type: "removed" as const, oldVal: String(oldVal) }]; } return [ { - key: displayKey, + key, type: "changed" as const, - oldVal: formatVal(oldVal), - newVal: formatVal(newVal), + oldVal: String(oldVal), + newVal: String(newVal), }, ]; }); diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index f04bed1a976..7571d27f67b 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -26,6 +26,22 @@ export const FeatureFlagCatalog = { export type FeatureFlagKey = keyof typeof FeatureFlagCatalog; +// Infrastructure flags that are read-only on the global flags page. +// Shown with current/resolved value but no controls. +export const GLOBAL_LOCKED_FLAGS: FeatureFlagKey[] = [ + FEATURE_FLAG.defaultWorkerInstanceGroupId, + FEATURE_FLAG.runsListRepository, + FEATURE_FLAG.taskEventRepository, +]; + +// Flags that are read-only on the org-level dialog. +// Shown with global value but no controls (org can't override these). +export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [ + FEATURE_FLAG.defaultWorkerInstanceGroupId, + FEATURE_FLAG.runsListRepository, + FEATURE_FLAG.taskEventRepository, +]; + // Create a Zod schema from the existing catalog export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog); export type FeatureFlagCatalog = z.infer; From 607af8007aa14d0ba19ca009d97b26553316ca19 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:11:43 +0100 Subject: [PATCH 40/40] fix: always skip locked flags in delete loop to prevent accidental deletion on self-hosted --- .../webapp/app/routes/admin.feature-flags.tsx | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 8d3841fcb2b..65e975880cb 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -124,15 +124,12 @@ export const action = async ({ request }: ActionFunctionArgs) => { const validatedFlags = validationResult.data as Record; const controlTypes = getAllFlagControlTypes(); - const lockedKeys = isManagedCloud ? GLOBAL_LOCKED_FLAGS : []; - const editableKeys = Object.keys(controlTypes).filter( - (key) => !lockedKeys.includes(key) - ); + const catalogKeys = Object.keys(controlTypes); const keysToDelete: string[] = []; const upsertOps: ReturnType[] = []; - for (const key of editableKeys) { + for (const key of catalogKeys) { if (key in validatedFlags) { upsertOps.push( prisma.featureFlag.upsert({ @@ -142,7 +139,13 @@ export const action = async ({ request }: ActionFunctionArgs) => { }) ); } else { - keysToDelete.push(key); + // On cloud, never delete locked flags (they're not in the payload + // because the UI doesn't include them). Locally, delete everything + // the user didn't include - full control. + const isProtected = isManagedCloud && GLOBAL_LOCKED_FLAGS.includes(key); + if (!isProtected) { + keysToDelete.push(key); + } } } @@ -219,8 +222,10 @@ export default function AdminFeatureFlagsRoute() { }); }; - const sortedFlagKeys = Object.keys(controlTypes as Record).sort(); + const typedControlTypes = controlTypes as Record; + const typedResolvedDefaults = resolvedDefaults as Record; const allFlags = (globalFlags ?? {}) as Record; + const sortedFlagKeys = Object.keys(typedControlTypes).sort(); const workerGroupMap = new Map((workerGroups as WorkerGroup[]).map((wg) => [wg.id, wg.name])); const resolveWorkerGroupDisplay = (id: string) => { @@ -253,7 +258,7 @@ export default function AdminFeatureFlagsRoute() {
{sortedFlagKeys.map((key) => { - const control = (controlTypes as Record)[key]; + const control = typedControlTypes[key]; const locked = isLocked(key); if (locked) { @@ -262,7 +267,7 @@ export default function AdminFeatureFlagsRoute() { key={key} flagKey={key} value={allFlags[key]} - resolvedDefault={(resolvedDefaults as Record)[key]} + resolvedDefault={typedResolvedDefaults[key]} workerGroupName={workerGroupName as string | undefined} /> ); @@ -295,7 +300,9 @@ export default function AdminFeatureFlagsRoute() { ? isWorkerGroup ? resolveWorkerGroupDisplay(values[key] as string) : `value: ${String(values[key])}` - : "not set"} + : typedResolvedDefaults[key] + ? `${typedResolvedDefaults[key]} (from env)` + : "not set"}
@@ -390,7 +397,7 @@ export default function AdminFeatureFlagsRoute() { onOpenChange={setConfirmOpen} initialValues={initialValues} newValues={values} - controlTypes={controlTypes as Record} + controlTypes={typedControlTypes} lockedKeys={unlocked ? [] : GLOBAL_LOCKED_FLAGS} onConfirm={handleSave} isSaving={isSaving}