chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import stableStringify from "json-stable-stringify";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { 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 { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
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,
|
||||
NumberControl,
|
||||
StringControl,
|
||||
WorkerGroupControl,
|
||||
type WorkerGroup,
|
||||
} from "~/components/admin/FlagControls";
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{ authorization: { requireSuper: true } },
|
||||
async ({ request }) => {
|
||||
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.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 = dashboardAction(
|
||||
{ authorization: { requireSuper: true } },
|
||||
async ({ request }) => {
|
||||
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">
|
||||
<Callout variant="warning">
|
||||
These are global feature flags that affect every organization on this instance. Changing
|
||||
values here is a dangerous operation and should rarely be done - prefer org-level
|
||||
overrides where possible. Org-level overrides take precedence; when a flag isn't set, each
|
||||
consumer uses its own default.
|
||||
</Callout>
|
||||
|
||||
<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-background-hover"
|
||||
)}
|
||||
>
|
||||
<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-text-dimmed">
|
||||
{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 === "number" && (
|
||||
<NumberControl
|
||||
value={isSet ? (values[key] as number) : undefined}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
onChange={(val) => {
|
||||
if (val === undefined) {
|
||||
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}
|
||||
saveError={saveError}
|
||||
/>
|
||||
</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-background-hover"
|
||||
)}
|
||||
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-text-dimmed">{displayValue}</div>
|
||||
</div>
|
||||
|
||||
<LockClosedIcon className="size-4 text-text-faint" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Confirmation Dialog with Diff ---
|
||||
|
||||
function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialValues,
|
||||
newValues,
|
||||
controlTypes,
|
||||
lockedKeys,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
saveError,
|
||||
}: {
|
||||
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;
|
||||
saveError: string | null;
|
||||
}) {
|
||||
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 max-h-[50vh] flex-col gap-2 overflow-y-auto 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-border-bright bg-background-bright 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>
|
||||
|
||||
{saveError && <Callout variant="error">{saveError}</Callout>}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user