diff --git a/.server-changes/admin-feature-flags-dialog.md b/.server-changes/admin-feature-flags-dialog.md new file mode 100644 index 00000000000..2517e21a3b8 --- /dev/null +++ b/.server-changes/admin-feature-flags-dialog.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add admin UI for viewing and editing feature flags (org-level overrides and global defaults). diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index 2acd5c02d77..00ed6aaf78b 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -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. diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx new file mode 100644 index 00000000000..df8669d36dd --- /dev/null +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -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; + globalFlags: Record; + controlTypes: Record; + 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(); + const saveFetcher = useFetcher(); + + 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) { + 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 ( + + + Feature flags - {orgTitle} + + Org-level overrides. Unset flags inherit from global defaults. + + + {data && ( +
+ +
+ )} + +
+ {isLoading ? ( +
Loading flags...
+ ) : data ? ( +
+ {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 ( +
+
+
{key}
+
global: {globalDisplay}
+
+ +
+ ); + } + + const isOverridden = key in overrides; + + return ( +
+
+
+ {key} +
+
global: {globalDisplay}
+
+ +
+ + + {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" ? ( + { + if (val === UNSET_VALUE) { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isOverridden} + /> + ) : control.type === "string" ? ( + { + if (val === "") { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isOverridden} + /> + ) : null} +
+
+ ); + })} +
+ ) : null} +
+ + {data && ( +
+ + Preview JSON + +
+              {jsonPreview}
+            
+
+ )} + + {saveError && {saveError}} + + + + + +
+
+ ); +} diff --git a/apps/webapp/app/components/admin/FlagControls.tsx b/apps/webapp/app/components/admin/FlagControls.tsx new file mode 100644 index 00000000000..b08f925dd90 --- /dev/null +++ b/apps/webapp/app/components/admin/FlagControls.tsx @@ -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 ( + + ); +} + +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 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, + 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/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index d19a2878653..591dba5753c 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -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; 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.v1.feature-flags.ts b/apps/webapp/app/routes/admin.api.v1.feature-flags.ts index d0e1dd6b274..b10c983b14d 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 @@ -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, }, 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..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 @@ -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, }, 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 new file mode 100644 index 00000000000..6081febb526 --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts @@ -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) + : ({ success: false } as const); + + 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, + 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; + + if ( + body === null || + (typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0) + ) { + featureFlags = Prisma.JsonNull; + } else { + const validationResult = validatePartialFeatureFlags(body as Record); + 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 }); +} 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..65e975880cb --- /dev/null +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -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 = { + [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) => { + 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; + const controlTypes = getAllFlagControlTypes(); + const catalogKeys = Object.keys(controlTypes); + + const keysToDelete: string[] = []; + const upsertOps: ReturnType[] = []; + + 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(); + 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; + // 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) { + 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; + 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) => { + const name = workerGroupMap.get(id); + return name ? `${name} (${id.slice(0, 8)}...)` : id; + }; + + return ( +
+
+

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

+ +
+ +
+ +
+ {sortedFlagKeys.map((key) => { + const control = typedControlTypes[key]; + const locked = isLocked(key); + + if (locked) { + return ( + + ); + } + + const isSet = key in values; + const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId; + + return ( +
+
+
+ {isWorkerGroup ? "defaultWorkerInstanceGroup" : key} +
+
+ {isSet + ? isWorkerGroup + ? resolveWorkerGroupDisplay(values[key] as string) + : `value: ${String(values[key])}` + : typedResolvedDefaults[key] + ? `${typedResolvedDefaults[key]} (from env)` + : "not set"} +
+
+ +
+ + + {isWorkerGroup ? ( + { + if (val === UNSET_VALUE) { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isSet} + /> + ) : ( + <> + {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}} + +
+ {isDirty && ( + + )} + +
+
+ + +
+ ); +} + +// --- 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 ( +
+
+
+ {isWorkerGroup ? "defaultWorkerInstanceGroup" : flagKey} +
+
{displayValue}
+
+ + +
+ ); +} + +// --- Confirmation Dialog with Diff --- + +function ConfirmDialog({ + open, + onOpenChange, + initialValues, + newValues, + controlTypes, + lockedKeys, + onConfirm, + isSaving, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + initialValues: Record; + newValues: Record; + controlTypes: Record; + 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((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 ( + + + 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}
+ + )} +
+ )) + )} +
+ + + + + +
+
+ ); +} diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index afdcf5e97c9..6f496362947 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,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export default function AdminDashboardRoute() { const { organizations, filters, page, pageCount } = useTypedLoaderData(); + const [flagsOrgId, setFlagsOrgId] = useState(null); + const [flagsOpen, setFlagsOpen] = useState(false); + const flagsOrgTitle = organizations.find((o) => o.id === flagsOrgId)?.title ?? ""; + + const openFlagsDialog = (orgId: string) => { + setFlagsOrgId(orgId); + setFlagsOpen(true); + }; + return (
{org.v3Enabled ? "✅" : ""} {org.deletedAt ? "☠️" : ""} - - Impersonate - +
+ + + Impersonate + +
); @@ -135,6 +153,12 @@ export default function AdminDashboardRoute() { +
); } 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", 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/canAccessPrivateConnections.server.ts b/apps/webapp/app/v3/canAccessPrivateConnections.server.ts index ffbd75b2ca5..a801a4d2f24 100644 --- a/apps/webapp/app/v3/canAccessPrivateConnections.server.ts +++ b/apps/webapp/app/v3/canAccessPrivateConnections.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 canAccessPrivateConnections(options: { organizationSlug: 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 cc977da7dcf..03353c914e6 100644 --- a/apps/webapp/app/v3/featureFlags.server.ts +++ b/apps/webapp/app/v3/featureFlags.server.ts @@ -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 = { key: T; @@ -43,7 +22,7 @@ export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) { async function flag( opts: FlagsOptions ): Promise | 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; - -// 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 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 new file mode 100644 index 00000000000..7571d27f67b --- /dev/null +++ b/apps/webapp/app/v3/featureFlags.ts @@ -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; + +// 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/computeTemplateCreation.server.ts b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts index 6c144b13448..4daa2667f25 100644 --- a/apps/webapp/app/v3/services/computeTemplateCreation.server.ts +++ b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts @@ -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"; 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"; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 0752ac8fe03..c1b75596cf8 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be0fe1a6f6b..15477faf4f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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