feat(webapp): admin UI for feature flag overrides (#3291)
Adds a dialog to the admin orgs page for viewing and editing per-org feature flag overrides. Flags are introspected from the catalog so the UI stays in sync with available flags automatically. Also adds a new tab for global flags. Refactors featureFlags.server.ts to split catalog definition (shared) from server-only runtime (flags(), makeSetMultipleFlags). The shared module exports flag metadata and validation so both the UI and API routes can use it without pulling in server dependencies.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Add admin UI for viewing and editing feature flags (org-level overrides and global defaults).
|
||||
@@ -98,3 +98,12 @@ The `app/v3/` directory name is misleading - most code is actively used by V2. O
|
||||
- `app/v3/sharedSocketConnection.ts`
|
||||
|
||||
Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths.
|
||||
|
||||
## Prisma Query Patterns
|
||||
|
||||
- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues.
|
||||
|
||||
## React Patterns
|
||||
|
||||
- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations.
|
||||
- Use named constants for sentinel/placeholder values (e.g. `const UNSET_VALUE = "__unset__"`) instead of raw string literals scattered across comparisons.
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import stableStringify from "json-stable-stringify";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { LockClosedIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { FEATURE_FLAG, ORG_LOCKED_FLAGS, type FlagControlType } from "~/v3/featureFlags";
|
||||
import {
|
||||
UNSET_VALUE,
|
||||
BooleanControl,
|
||||
EnumControl,
|
||||
StringControl,
|
||||
WorkerGroupControl,
|
||||
type WorkerGroup,
|
||||
} from "./FlagControls";
|
||||
|
||||
type LoaderData = {
|
||||
org: { id: string; title: string; slug: string };
|
||||
orgFlags: Record<string, unknown>;
|
||||
globalFlags: Record<string, unknown>;
|
||||
controlTypes: Record<string, FlagControlType>;
|
||||
workerGroupName?: string;
|
||||
workerGroups?: WorkerGroup[];
|
||||
isManagedCloud?: boolean;
|
||||
};
|
||||
|
||||
type ActionData = {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type FeatureFlagsDialogProps = {
|
||||
orgId: string | null;
|
||||
orgTitle: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function FeatureFlagsDialog({
|
||||
orgId,
|
||||
orgTitle,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FeatureFlagsDialogProps) {
|
||||
const loadFetcher = useFetcher<LoaderData>();
|
||||
const saveFetcher = useFetcher<ActionData>();
|
||||
|
||||
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
|
||||
const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [unlocked, setUnlocked] = useState(false);
|
||||
|
||||
const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && orgId) {
|
||||
setSaveError(null);
|
||||
setOverrides({});
|
||||
setInitialOverrides({});
|
||||
loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`);
|
||||
}
|
||||
}, [open, orgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadFetcher.data) {
|
||||
const loaded = loadFetcher.data.orgFlags ?? {};
|
||||
setOverrides({ ...loaded });
|
||||
setInitialOverrides({ ...loaded });
|
||||
}
|
||||
}, [loadFetcher.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveFetcher.data?.success) {
|
||||
onOpenChange(false);
|
||||
} else if (saveFetcher.data?.error) {
|
||||
setSaveError(saveFetcher.data.error);
|
||||
}
|
||||
}, [saveFetcher.data]);
|
||||
|
||||
const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides);
|
||||
|
||||
const setFlagValue = (key: string, value: unknown) => {
|
||||
setOverrides((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const unsetFlag = (key: string) => {
|
||||
setOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!orgId) return;
|
||||
const body = Object.keys(overrides).length === 0 ? null : overrides;
|
||||
saveFetcher.submit(JSON.stringify(body), {
|
||||
method: "POST",
|
||||
action: `/admin/api/v2/orgs/${orgId}/feature-flags`,
|
||||
encType: "application/json",
|
||||
});
|
||||
};
|
||||
|
||||
const data = loadFetcher.data;
|
||||
const isLoading = loadFetcher.state === "loading";
|
||||
const isSaving = saveFetcher.state === "submitting";
|
||||
|
||||
const jsonPreview =
|
||||
Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2);
|
||||
|
||||
const sortedFlagKeys = data ? Object.keys(data.controlTypes).sort() : [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>Feature flags - {orgTitle}</DialogHeader>
|
||||
<DialogDescription>
|
||||
Org-level overrides. Unset flags inherit from global defaults.
|
||||
</DialogDescription>
|
||||
|
||||
{data && (
|
||||
<div className={data.isManagedCloud ? "cursor-not-allowed" : undefined}>
|
||||
<CheckboxWithLabel
|
||||
variant="simple/small"
|
||||
label={
|
||||
data.isManagedCloud
|
||||
? "Unlock read-only flags (only in unmanaged cloud)"
|
||||
: "Unlock read-only flags"
|
||||
}
|
||||
defaultChecked={unlocked}
|
||||
onChange={setUnlocked}
|
||||
disabled={data.isManagedCloud}
|
||||
className={data.isManagedCloud ? "pointer-events-none" : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-text-dimmed">Loading flags...</div>
|
||||
) : data ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{sortedFlagKeys.map((key) => {
|
||||
const control = data.controlTypes[key];
|
||||
const locked = isLocked(key);
|
||||
const globalValue = data.globalFlags[key as keyof typeof data.globalFlags];
|
||||
const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId;
|
||||
const globalDisplay =
|
||||
isWorkerGroup && data.workerGroupName && globalValue !== undefined
|
||||
? `${data.workerGroupName} (${String(globalValue).slice(0, 8)}...)`
|
||||
: globalValue !== undefined
|
||||
? String(globalValue)
|
||||
: "unset";
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between rounded-md border border-transparent bg-charcoal-750 px-3 py-2.5"
|
||||
title="Global-level setting - not editable per org"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-text-dimmed">{key}</div>
|
||||
<div className="text-xs text-charcoal-400">global: {globalDisplay}</div>
|
||||
</div>
|
||||
<LockClosedIcon className="size-4 text-charcoal-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOverridden = key in overrides;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md border px-3 py-2.5",
|
||||
isOverridden
|
||||
? "border-indigo-500/20 bg-indigo-500/5"
|
||||
: "border-transparent bg-charcoal-750"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
isOverridden ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</div>
|
||||
<div className="text-xs text-charcoal-400">global: {globalDisplay}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => unsetFlag(key)}
|
||||
className={cn(!isOverridden && "invisible")}
|
||||
>
|
||||
unset
|
||||
</Button>
|
||||
|
||||
{isWorkerGroup && data.workerGroups ? (
|
||||
<WorkerGroupControl
|
||||
value={isOverridden ? (overrides[key] as string) : undefined}
|
||||
workerGroups={data.workerGroups as WorkerGroup[]}
|
||||
onChange={(val) => {
|
||||
if (val === UNSET_VALUE) {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : control.type === "boolean" ? (
|
||||
<BooleanControl
|
||||
value={isOverridden ? (overrides[key] as boolean) : undefined}
|
||||
onChange={(val) => setFlagValue(key, val)}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : control.type === "enum" ? (
|
||||
<EnumControl
|
||||
value={isOverridden ? (overrides[key] as string) : undefined}
|
||||
options={control.options}
|
||||
onChange={(val) => {
|
||||
if (val === UNSET_VALUE) {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : control.type === "string" ? (
|
||||
<StringControl
|
||||
value={isOverridden ? (overrides[key] as string) : ""}
|
||||
onChange={(val) => {
|
||||
if (val === "") {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-text-dimmed hover:text-text-bright">
|
||||
Preview JSON
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded bg-charcoal-800 p-2 text-xs text-text-dimmed">
|
||||
{jsonPreview}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{saveError && <Callout variant="error">{saveError}</Callout>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary/small" onClick={handleSave} disabled={!isDirty || isSaving}>
|
||||
{isSaving ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={value ?? false}
|
||||
onCheckedChange={onChange}
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnumControl({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
dimmed,
|
||||
}: {
|
||||
value: string | undefined;
|
||||
options: string[];
|
||||
onChange: (val: string) => void;
|
||||
dimmed: boolean;
|
||||
}) {
|
||||
const items = [UNSET_VALUE, ...options];
|
||||
|
||||
return (
|
||||
<Select
|
||||
variant="tertiary/small"
|
||||
value={value ?? UNSET_VALUE}
|
||||
setValue={onChange}
|
||||
items={items}
|
||||
text={(val) => (val === UNSET_VALUE ? "unset" : val)}
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === UNSET_VALUE ? "unset" : item}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Select
|
||||
variant="tertiary/small"
|
||||
value={value ?? UNSET_VALUE}
|
||||
setValue={onChange}
|
||||
items={items}
|
||||
text={(val) => {
|
||||
if (val === UNSET_VALUE) return "unset";
|
||||
const wg = workerGroups.find((w) => w.id === val);
|
||||
return wg ? wg.name : val;
|
||||
}}
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => {
|
||||
const wg = workerGroups.find((w) => w.id === item);
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === UNSET_VALUE ? "unset" : wg ? wg.name : item}
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function StringControl({
|
||||
value,
|
||||
onChange,
|
||||
dimmed,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
dimmed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
variant="small"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="unset"
|
||||
className={cn("w-40", dimmed && "opacity-50")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
|
||||
import { env } from "~/env.server";
|
||||
import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server";
|
||||
import { flags } from "~/v3/featureFlags.server";
|
||||
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+1
-1
@@ -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"];
|
||||
|
||||
@@ -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
|
||||
@@ -11,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,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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";
|
||||
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
|
||||
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).
|
||||
// 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({
|
||||
organizationId: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
if (!user.admin) {
|
||||
throw new Response("Unauthorized", { status: 403 });
|
||||
}
|
||||
|
||||
const { organizationId } = ParamsSchema.parse(params);
|
||||
|
||||
const [organization, globalFlags, workerGroups] = await Promise.all([
|
||||
prisma.organization.findFirst({
|
||||
where: { id: organizationId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
featureFlags: true,
|
||||
},
|
||||
}),
|
||||
getGlobalFlags(),
|
||||
prisma.workerInstanceGroup.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Response("Organization not found", { status: 404 });
|
||||
}
|
||||
|
||||
const orgFlagsResult = organization.featureFlags
|
||||
? validatePartialFeatureFlags(organization.featureFlags as Record<string, unknown>)
|
||||
: ({ success: false } as const);
|
||||
|
||||
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
|
||||
const controlTypes = getAllFlagControlTypes();
|
||||
|
||||
// Resolve worker group name for display
|
||||
const workerGroupId = (globalFlags as Record<string, unknown>)?.[
|
||||
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,
|
||||
title: organization.title,
|
||||
slug: organization.slug,
|
||||
},
|
||||
orgFlags,
|
||||
globalFlags,
|
||||
controlTypes,
|
||||
workerGroupName,
|
||||
workerGroups,
|
||||
isManagedCloud,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
if (!user.admin) {
|
||||
throw new Response("Unauthorized", { status: 403 });
|
||||
}
|
||||
|
||||
const { organizationId } = ParamsSchema.parse(params);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
let featureFlags: typeof Prisma.JsonNull | Record<string, unknown>;
|
||||
|
||||
if (
|
||||
body === null ||
|
||||
(typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0)
|
||||
) {
|
||||
featureFlags = Prisma.JsonNull;
|
||||
} else {
|
||||
const validationResult = validatePartialFeatureFlags(body as Record<string, unknown>);
|
||||
if (!validationResult.success) {
|
||||
return json(
|
||||
{ error: "Invalid feature flags", details: validationResult.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
featureFlags = validationResult.data;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { featureFlags: featureFlags as Prisma.InputJsonValue },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
|
||||
throw new Response("Organization not found", { status: 404 });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
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 { 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,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
UNSET_VALUE,
|
||||
BooleanControl,
|
||||
EnumControl,
|
||||
StringControl,
|
||||
WorkerGroupControl,
|
||||
type WorkerGroup,
|
||||
} from "~/components/admin/FlagControls";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
if (!user.admin) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const [globalFlags, workerGroups] = await Promise.all([
|
||||
getGlobalFlags(),
|
||||
prisma.workerInstanceGroup.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
const controlTypes = getAllFlagControlTypes();
|
||||
|
||||
// Resolve env-based defaults for locked flags
|
||||
const resolvedDefaults: Record<string, string> = {
|
||||
[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<string, unknown>)?.[
|
||||
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) => {
|
||||
const user = await requireUser(request);
|
||||
if (!user.admin) {
|
||||
throw new Response("Unauthorized", { status: 403 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payloadSchema = z.object({ flags: z.record(z.unknown()) });
|
||||
const parsed = payloadSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
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(
|
||||
{ error: "Invalid feature flags", details: validationResult.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validatedFlags = validationResult.data as Record<string, unknown>;
|
||||
const controlTypes = getAllFlagControlTypes();
|
||||
const catalogKeys = Object.keys(controlTypes);
|
||||
|
||||
const keysToDelete: string[] = [];
|
||||
const upsertOps: ReturnType<typeof prisma.featureFlag.upsert>[] = [];
|
||||
|
||||
for (const key of catalogKeys) {
|
||||
if (key in validatedFlags) {
|
||||
upsertOps.push(
|
||||
prisma.featureFlag.upsert({
|
||||
where: { key },
|
||||
create: { key, value: validatedFlags[key] as any },
|
||||
update: { value: validatedFlags[key] as any },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
...upsertOps,
|
||||
...(keysToDelete.length > 0
|
||||
? [prisma.featureFlag.deleteMany({ where: { key: { in: keysToDelete } } })]
|
||||
: []),
|
||||
]);
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
export default function AdminFeatureFlagsRoute() {
|
||||
const {
|
||||
globalFlags,
|
||||
controlTypes,
|
||||
resolvedDefaults,
|
||||
workerGroupName,
|
||||
workerGroups,
|
||||
isManagedCloud,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const saveFetcher = useFetcher<{ success?: boolean; error?: string }>();
|
||||
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [initialValues, setInitialValues] = useState<Record<string, unknown>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(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<string, unknown>;
|
||||
// Only track editable flags in state
|
||||
const editable: Record<string, unknown> = {};
|
||||
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) {
|
||||
setSaveError(null);
|
||||
setConfirmOpen(false);
|
||||
} else if (saveFetcher.data?.error) {
|
||||
setSaveError(saveFetcher.data.error);
|
||||
}
|
||||
}, [saveFetcher.data]);
|
||||
|
||||
const isDirty = stableStringify(values) !== stableStringify(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 typedControlTypes = controlTypes as Record<string, FlagControlType>;
|
||||
const typedResolvedDefaults = resolvedDefaults as Record<string, string>;
|
||||
const allFlags = (globalFlags ?? {}) as Record<string, unknown>;
|
||||
const sortedFlagKeys = Object.keys(typedControlTypes).sort();
|
||||
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;
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<p className="text-sm text-text-dimmed">
|
||||
Global defaults for all organizations. Org-level overrides take precedence. When not set,
|
||||
each consumer uses its own default.
|
||||
</p>
|
||||
|
||||
<div className={isManagedCloud ? "cursor-not-allowed" : undefined}>
|
||||
<CheckboxWithLabel
|
||||
variant="simple/small"
|
||||
label={
|
||||
isManagedCloud
|
||||
? "Unlock read-only flags (only in unmanaged cloud)"
|
||||
: "Unlock read-only flags"
|
||||
}
|
||||
defaultChecked={unlocked}
|
||||
onChange={setUnlocked}
|
||||
disabled={isManagedCloud}
|
||||
className={isManagedCloud ? "pointer-events-none" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{sortedFlagKeys.map((key) => {
|
||||
const control = typedControlTypes[key];
|
||||
const locked = isLocked(key);
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<LockedFlagRow
|
||||
key={key}
|
||||
flagKey={key}
|
||||
value={allFlags[key]}
|
||||
resolvedDefault={typedResolvedDefaults[key]}
|
||||
workerGroupName={workerGroupName as string | undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isSet = key in values;
|
||||
const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md border px-3 py-2.5",
|
||||
isSet
|
||||
? "border-indigo-500/20 bg-indigo-500/5"
|
||||
: "border-transparent bg-charcoal-750"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
isSet ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{isWorkerGroup ? "defaultWorkerInstanceGroup" : key}
|
||||
</div>
|
||||
<div className="text-xs text-charcoal-400">
|
||||
{isSet
|
||||
? isWorkerGroup
|
||||
? resolveWorkerGroupDisplay(values[key] as string)
|
||||
: `value: ${String(values[key])}`
|
||||
: typedResolvedDefaults[key]
|
||||
? `${typedResolvedDefaults[key]} (from env)`
|
||||
: "not set"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => unsetFlag(key)}
|
||||
className={cn(!isSet && "invisible")}
|
||||
>
|
||||
unset
|
||||
</Button>
|
||||
|
||||
{isWorkerGroup ? (
|
||||
<WorkerGroupControl
|
||||
value={isSet ? (values[key] as string) : undefined}
|
||||
workerGroups={workerGroups as WorkerGroup[]}
|
||||
onChange={(val) => {
|
||||
if (val === UNSET_VALUE) {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isSet}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{control.type === "boolean" && (
|
||||
<BooleanControl
|
||||
value={isSet ? (values[key] as boolean) : undefined}
|
||||
onChange={(val) => setFlagValue(key, val)}
|
||||
dimmed={!isSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{control.type === "enum" && (
|
||||
<EnumControl
|
||||
value={isSet ? (values[key] as string) : undefined}
|
||||
options={control.options}
|
||||
onChange={(val) => {
|
||||
if (val === UNSET_VALUE) {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{control.type === "string" && (
|
||||
<StringControl
|
||||
value={isSet ? (values[key] as string) : ""}
|
||||
onChange={(val) => {
|
||||
if (val === "") {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isSet}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{saveError && <Callout variant="error">{saveError}</Callout>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{isDirty && (
|
||||
<Button variant="tertiary/small" onClick={() => setValues({ ...initialValues })}>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary/small"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
Review changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
initialValues={initialValues}
|
||||
newValues={values}
|
||||
controlTypes={typedControlTypes}
|
||||
lockedKeys={unlocked ? [] : GLOBAL_LOCKED_FLAGS}
|
||||
onConfirm={handleSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Locked Flag Row ---
|
||||
|
||||
function LockedFlagRow({
|
||||
flagKey,
|
||||
value,
|
||||
resolvedDefault,
|
||||
workerGroupName,
|
||||
}: {
|
||||
flagKey: string;
|
||||
value: unknown;
|
||||
resolvedDefault: string | undefined;
|
||||
workerGroupName: string | undefined;
|
||||
}) {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md border px-3 py-2.5",
|
||||
isSet ? "border-indigo-500/20 bg-indigo-500/5" : "border-transparent bg-charcoal-750"
|
||||
)}
|
||||
title="Managed via database - not editable from this UI"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn("truncate text-sm", isSet ? "text-text-bright" : "text-text-dimmed")}
|
||||
>
|
||||
{isWorkerGroup ? "defaultWorkerInstanceGroup" : flagKey}
|
||||
</div>
|
||||
<div className="text-xs text-charcoal-400">{displayValue}</div>
|
||||
</div>
|
||||
|
||||
<LockClosedIcon className="size-4 text-charcoal-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Confirmation Dialog with Diff ---
|
||||
|
||||
function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialValues,
|
||||
newValues,
|
||||
controlTypes,
|
||||
lockedKeys,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialValues: Record<string, unknown>;
|
||||
newValues: Record<string, unknown>;
|
||||
controlTypes: Record<string, FlagControlType>;
|
||||
lockedKeys: readonly string[];
|
||||
onConfirm: () => void;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
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 = editableKeys.flatMap<Change>((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 [];
|
||||
|
||||
if (!wasSet && isSet) {
|
||||
return [{ key, type: "added" as const, newVal: String(newVal) }];
|
||||
}
|
||||
if (wasSet && !isSet) {
|
||||
return [{ key, type: "removed" as const, oldVal: String(oldVal) }];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key,
|
||||
type: "changed" as const,
|
||||
oldVal: String(oldVal),
|
||||
newVal: String(newVal),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>Confirm feature flag changes</DialogHeader>
|
||||
<DialogDescription>
|
||||
These changes affect all organizations globally. Please review carefully.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex flex-col gap-2 pb-2">
|
||||
{changes.length === 0 ? (
|
||||
<p className="text-sm text-text-dimmed">No changes to apply.</p>
|
||||
) : (
|
||||
changes.map((change) => (
|
||||
<div
|
||||
key={change.key}
|
||||
className="rounded-md border border-charcoal-600 bg-charcoal-800 px-3 py-2 font-mono text-xs"
|
||||
>
|
||||
<div className="font-sans text-sm text-text-bright">{change.key}</div>
|
||||
{change.type === "added" && (
|
||||
<div className="mt-1 text-green-400">+ {change.newVal}</div>
|
||||
)}
|
||||
{change.type === "removed" && (
|
||||
<div className="mt-1 text-red-400">- {change.oldVal} (unset)</div>
|
||||
)}
|
||||
{change.type === "changed" && (
|
||||
<>
|
||||
<div className="mt-1 text-red-400">- {change.oldVal}</div>
|
||||
<div className="text-green-400">+ {change.newVal}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger/small"
|
||||
onClick={onConfirm}
|
||||
disabled={isSaving || changes.length === 0}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Apply changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
export default function AdminDashboardRoute() {
|
||||
const { organizations, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const [flagsOrgId, setFlagsOrgId] = useState<string | null>(null);
|
||||
const [flagsOpen, setFlagsOpen] = useState(false);
|
||||
const flagsOrgTitle = organizations.find((o) => o.id === flagsOrgId)?.title ?? "";
|
||||
|
||||
const openFlagsDialog = (orgId: string) => {
|
||||
setFlagsOrgId(orgId);
|
||||
setFlagsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
aria-labelledby="primary-heading"
|
||||
@@ -113,18 +124,25 @@ export default function AdminDashboardRoute() {
|
||||
<TableCell>{org.v3Enabled ? "✅" : ""}</TableCell>
|
||||
<TableCell>{org.deletedAt ? "☠️" : ""}</TableCell>
|
||||
<TableCell isSticky={true}>
|
||||
<LinkButton
|
||||
to={`/@/orgs/${org.slug}`}
|
||||
className="mr-2"
|
||||
variant="tertiary/small"
|
||||
shortcut={
|
||||
organizations.length === 1
|
||||
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Impersonate
|
||||
</LinkButton>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
onClick={() => openFlagsDialog(org.id)}
|
||||
>
|
||||
Flags
|
||||
</Button>
|
||||
<LinkButton
|
||||
to={`/@/orgs/${org.slug}`}
|
||||
variant="tertiary/small"
|
||||
shortcut={
|
||||
organizations.length === 1
|
||||
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Impersonate
|
||||
</LinkButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -135,6 +153,12 @@ export default function AdminDashboardRoute() {
|
||||
|
||||
<PaginationControls currentPage={page} totalPages={pageCount} />
|
||||
</div>
|
||||
<FeatureFlagsDialog
|
||||
orgId={flagsOrgId}
|
||||
orgTitle={flagsOrgTitle}
|
||||
open={flagsOpen}
|
||||
onOpenChange={setFlagsOpen}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 canAccessPrivateConnections(options: {
|
||||
organizationSlug: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { type z } from "zod";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
|
||||
export const FEATURE_FLAG = {
|
||||
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
|
||||
runsListRepository: "runsListRepository",
|
||||
taskEventRepository: "taskEventRepository",
|
||||
hasQueryAccess: "hasQueryAccess",
|
||||
hasLogsPageAccess: "hasLogsPageAccess",
|
||||
hasAiAccess: "hasAiAccess",
|
||||
hasAiModelsAccess: "hasAiModelsAccess",
|
||||
hasComputeAccess: "hasComputeAccess",
|
||||
hasPrivateConnections: "hasPrivateConnections",
|
||||
} as const;
|
||||
|
||||
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(),
|
||||
[FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(),
|
||||
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
|
||||
};
|
||||
|
||||
type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
|
||||
import {
|
||||
type FeatureFlagKey,
|
||||
FeatureFlagCatalog,
|
||||
FeatureFlagCatalogSchema,
|
||||
} from "~/v3/featureFlags";
|
||||
|
||||
export type FlagsOptions<T extends FeatureFlagKey> = {
|
||||
key: T;
|
||||
@@ -43,7 +22,7 @@ export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
async function flag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T>
|
||||
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined> {
|
||||
const value = await _prisma.featureFlag.findUnique({
|
||||
const value = await _prisma.featureFlag.findFirst({
|
||||
where: {
|
||||
key: opts.key,
|
||||
},
|
||||
@@ -146,28 +125,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<typeof FeatureFlagCatalogSchema>;
|
||||
|
||||
// Utility function to validate a feature flag value
|
||||
export function validateFeatureFlagValue<T extends FeatureFlagKey>(
|
||||
key: T,
|
||||
value: unknown
|
||||
): z.SafeParseReturnType<unknown, z.infer<(typeof FeatureFlagCatalog)[T]>> {
|
||||
return FeatureFlagCatalog[key].safeParse(value);
|
||||
}
|
||||
|
||||
// Utility function to validate all feature flags at once
|
||||
export function validateAllFeatureFlags(values: Record<string, unknown>) {
|
||||
return FeatureFlagCatalogSchema.safeParse(values);
|
||||
}
|
||||
|
||||
// Utility function to validate partial feature flags (all keys optional)
|
||||
export function validatePartialFeatureFlags(values: Record<string, unknown>) {
|
||||
return FeatureFlagCatalogSchema.partial().safeParse(values);
|
||||
}
|
||||
|
||||
// Utility function to set multiple feature flags at once
|
||||
export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return async function setMultipleFlags(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const FEATURE_FLAG = {
|
||||
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
|
||||
runsListRepository: "runsListRepository",
|
||||
taskEventRepository: "taskEventRepository",
|
||||
hasQueryAccess: "hasQueryAccess",
|
||||
hasLogsPageAccess: "hasLogsPageAccess",
|
||||
hasAiAccess: "hasAiAccess",
|
||||
hasAiModelsAccess: "hasAiModelsAccess",
|
||||
hasComputeAccess: "hasComputeAccess",
|
||||
hasPrivateConnections: "hasPrivateConnections",
|
||||
} 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(),
|
||||
[FEATURE_FLAG.hasComputeAccess]: z.coerce.boolean(),
|
||||
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
|
||||
};
|
||||
|
||||
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<typeof FeatureFlagCatalogSchema>;
|
||||
|
||||
// Utility function to validate a feature flag value
|
||||
export function validateFeatureFlagValue<T extends FeatureFlagKey>(
|
||||
key: T,
|
||||
value: unknown
|
||||
): z.SafeParseReturnType<unknown, z.infer<(typeof FeatureFlagCatalog)[T]>> {
|
||||
return FeatureFlagCatalog[key].safeParse(value);
|
||||
}
|
||||
|
||||
// Utility function to validate all feature flags at once
|
||||
export function validateAllFeatureFlags(values: Record<string, unknown>) {
|
||||
return FeatureFlagCatalogSchema.safeParse(values);
|
||||
}
|
||||
|
||||
// Utility function to validate partial feature flags (all keys optional)
|
||||
export function validatePartialFeatureFlags(values: Record<string, unknown>) {
|
||||
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<string, FlagControlType> {
|
||||
const result: Record<string, FlagControlType> = {};
|
||||
for (const [key, schema] of Object.entries(FeatureFlagCatalog)) {
|
||||
result[key] = getFlagControlType(schema);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { machinePresetFromName } from "~/v3/machinePresets.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import 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 type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { ServiceValidationError } from "./baseService.server";
|
||||
import { FailDeploymentService } from "./failDeployment.server";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -162,6 +162,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",
|
||||
|
||||
Generated
+9
-6
@@ -629,6 +629,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
|
||||
@@ -1131,7 +1134,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
|
||||
@@ -39434,7 +39437,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
|
||||
@@ -39471,8 +39474,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
|
||||
@@ -40699,7 +40702,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)
|
||||
@@ -40728,7 +40731,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
|
||||
|
||||
Reference in New Issue
Block a user