Env vars page performance fix (#3829)
## Summary This PR improves performance across the Environment Variables page. ## Changes ### Targeted value loading - load only the non-secret (environmentId, key) pairs required by the page. Secret values continue to be redacted in the UI. ### SSR windowing + virtualization - SSR-render only the first 50 rows - hydrate those rows - virtualize the remaining dataset client-side - search is now URL-driven during SSR, ensuring deep links such as `?search=DATABASE_URL` ### Lightweight 'Create' flow - 'Create' page no longer loads the full Environment Variables dataset. ## Results Large projects no longer render thousands of rows during SSR. Example (~11k rendered rows): Metric | Before | After -- | -- | -- Document size | ~150 MB | ~5 MB SSR rows | ~11k | 50 Browser DOM rows | Thousands | ~26–38
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix `@trigger.dev/core` build: cast the underlying log record exporter when calling `forceFlush` so it typechecks against the updated OpenTelemetry `LogRecordExporter` type (which no longer declares `forceFlush`).
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Make the Environment Variables page fast for projects with many variables across many environments (windowed SSR + table virtualization, decrypt only non-secret values, lightweight create-page loaders)
|
||||
@@ -130,12 +130,13 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
|
||||
type TableBodyProps = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
({ className, children }, ref) => {
|
||||
({ className, children, style }, ref) => {
|
||||
return (
|
||||
<tbody ref={ref} className={cn("relative overflow-y-auto", className)}>
|
||||
<tbody ref={ref} className={cn("relative overflow-y-auto", className)} style={style}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository";
|
||||
import {
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
EnvSlug,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
|
||||
import { loadEnvironmentVariablesEnvironments } from "./environmentVariablesEnvironments.server";
|
||||
|
||||
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
|
||||
export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number];
|
||||
@@ -62,16 +62,7 @@ export class EnvironmentVariablesPresenter {
|
||||
},
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,32 +102,29 @@ export class EnvironmentVariablesPresenter {
|
||||
const usersRecord: Record<string, { id: string; name: string | null; displayName: string | null; avatarUrl: string | null }> =
|
||||
Object.fromEntries(users.map((u) => [u.id, u]));
|
||||
|
||||
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
isBranchableEnvironment: true,
|
||||
branchName: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
},
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter(
|
||||
(e) => e.orgMember?.userId === userId || e.orgMember === null
|
||||
);
|
||||
const { environments: sortedEnvironments, hasStaging } =
|
||||
await loadEnvironmentVariablesEnvironments(
|
||||
this.#prismaClient,
|
||||
{ userId, projectId: project.id },
|
||||
{ skipProjectAccessCheck: true }
|
||||
);
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(this.#prismaClient);
|
||||
const variables = await repository.getProject(project.id);
|
||||
|
||||
const nonSecretItems: Array<{ environmentId: string; key: string }> = [];
|
||||
for (const environmentVariable of environmentVariables) {
|
||||
for (const env of sortedEnvironments) {
|
||||
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
|
||||
if (valueRecord && !valueRecord.isSecret) {
|
||||
nonSecretItems.push({ environmentId: env.id, key: environmentVariable.key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const variableValuesByEnvAndKey = await repository.getVariableValuesForKeys(
|
||||
project.id,
|
||||
nonSecretItems
|
||||
);
|
||||
|
||||
// Get Vercel integration data if it exists
|
||||
const vercelService = new VercelIntegrationService(this.#prismaClient);
|
||||
@@ -153,14 +141,19 @@ export class EnvironmentVariablesPresenter {
|
||||
return {
|
||||
environmentVariables: environmentVariables
|
||||
.flatMap((environmentVariable) => {
|
||||
const variable = variables.find((v) => v.key === environmentVariable.key);
|
||||
|
||||
return sortedEnvironments.flatMap((env) => {
|
||||
const val = variable?.values.find((v) => v.environment.id === env.id);
|
||||
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
|
||||
const isSecret = valueRecord?.isSecret ?? false;
|
||||
|
||||
if (!val || !valueRecord) {
|
||||
if (!valueRecord) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const val = isSecret
|
||||
? undefined
|
||||
: variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`);
|
||||
|
||||
if (!isSecret && val === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -185,7 +178,7 @@ export class EnvironmentVariablesPresenter {
|
||||
id: environmentVariable.id,
|
||||
key: environmentVariable.key,
|
||||
environment: { type: env.type, id: env.id, branchName: env.branchName },
|
||||
value: isSecret ? "" : val.value,
|
||||
value: isSecret ? "" : val!,
|
||||
isSecret,
|
||||
version: valueRecord.version,
|
||||
lastUpdatedBy,
|
||||
@@ -196,13 +189,8 @@ export class EnvironmentVariablesPresenter {
|
||||
});
|
||||
})
|
||||
.sort((a, b) => a.key.localeCompare(b.key)),
|
||||
environments: sortedEnvironments.map((environment) => ({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
isBranchableEnvironment: environment.isBranchableEnvironment,
|
||||
branchName: environment.branchName,
|
||||
})),
|
||||
hasStaging: environments.some((environment) => environment.type === "STAGING"),
|
||||
environments: sortedEnvironments,
|
||||
hasStaging,
|
||||
// Vercel integration data
|
||||
vercelIntegration: vercelIntegration
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { type PrismaClient } from "~/db.server";
|
||||
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export type EnvironmentVariablesEnvironment = {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
isBranchableEnvironment: boolean;
|
||||
branchName: string | null;
|
||||
};
|
||||
|
||||
export type EnvironmentVariablesEnvironmentsResult = {
|
||||
environments: EnvironmentVariablesEnvironment[];
|
||||
hasStaging: boolean;
|
||||
};
|
||||
|
||||
export async function loadEnvironmentVariablesEnvironments(
|
||||
prismaClient: PrismaClient,
|
||||
{ userId, projectId }: { userId: string; projectId: string },
|
||||
options?: { skipProjectAccessCheck?: boolean }
|
||||
): Promise<EnvironmentVariablesEnvironmentsResult> {
|
||||
if (!options?.skipProjectAccessCheck) {
|
||||
const project = await prismaClient.project.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: projectId,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
}
|
||||
|
||||
const environments = await prismaClient.runtimeEnvironment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
isBranchableEnvironment: true,
|
||||
branchName: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
projectId,
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const sortedEnvironments = sortEnvironments(filterOrphanedEnvironments(environments)).filter(
|
||||
(environment) => environment.orgMember?.userId === userId || environment.orgMember === null
|
||||
);
|
||||
|
||||
return {
|
||||
environments: sortedEnvironments.map((environment) => ({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
isBranchableEnvironment: environment.isBranchableEnvironment,
|
||||
branchName: environment.branchName,
|
||||
})),
|
||||
hasStaging: environments.some((environment) => environment.type === "STAGING"),
|
||||
};
|
||||
}
|
||||
+18
-35
@@ -9,10 +9,11 @@ import {
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { LockClosedIcon, LockOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import dotenv from "dotenv";
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { type RefObject, useCallback, useRef, useState } from "react";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
@@ -39,13 +40,15 @@ import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useList } from "~/hooks/useList";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
environmentVariablesRouteId,
|
||||
type loader as environmentVariablesLoader,
|
||||
} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
v3BillingPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
@@ -53,29 +56,6 @@ import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/enviro
|
||||
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
try {
|
||||
const presenter = new EnvironmentVariablesPresenter();
|
||||
const { environments, hasStaging } = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
environments,
|
||||
hasStaging,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const Variable = z.object({
|
||||
key: EnvironmentVariableKey,
|
||||
value: z.string().nonempty("Value is required"),
|
||||
@@ -185,8 +165,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { environments, hasStaging } = useTypedLoaderData<typeof loader>();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const parentData = useTypedMatchesData<typeof environmentVariablesLoader>({
|
||||
id: environmentVariablesRouteId,
|
||||
});
|
||||
invariant(
|
||||
parentData,
|
||||
"Environment variables page loader data must be defined when rendering the create dialog"
|
||||
);
|
||||
const { environments, hasStaging } = parentData;
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
@@ -259,10 +246,6 @@ export default function Page() {
|
||||
|
||||
const [revealAll, setRevealAll] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
|
||||
+327
-181
@@ -18,7 +18,15 @@ import {
|
||||
useRevalidator,
|
||||
} from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
@@ -61,6 +69,7 @@ import {
|
||||
type EnvironmentVariableWithSetValues,
|
||||
EnvironmentVariablesPresenter,
|
||||
} from "~/presenters/v3/EnvironmentVariablesPresenter.server";
|
||||
import { type EnvironmentVariablesEnvironment } from "~/presenters/v3/environmentVariablesEnvironments.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
@@ -94,6 +103,20 @@ export const meta: MetaFunction = () => {
|
||||
];
|
||||
};
|
||||
|
||||
type PageVercelIntegration = NonNullable<
|
||||
Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>["vercelIntegration"]
|
||||
>;
|
||||
|
||||
export type EnvironmentVariablesPageLoaderData = {
|
||||
environmentVariables: EnvironmentVariableWithSetValues[];
|
||||
environments: EnvironmentVariablesEnvironment[];
|
||||
hasStaging: boolean;
|
||||
vercelIntegration: PageVercelIntegration | null;
|
||||
};
|
||||
|
||||
export const environmentVariablesRouteId =
|
||||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam } = ProjectParamSchema.parse(params);
|
||||
@@ -258,27 +281,45 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
}
|
||||
};
|
||||
|
||||
const SSR_ROW_WINDOW = 50;
|
||||
const ROW_ESTIMATE_HEIGHT = 44;
|
||||
const VIRTUAL_OVERSCAN = 10;
|
||||
|
||||
type GroupedEnvironmentVariable = EnvironmentVariableWithSetValues & {
|
||||
isFirstTime: boolean;
|
||||
isLastTime: boolean;
|
||||
occurences: number;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const loaderData = useTypedLoaderData<EnvironmentVariablesPageLoaderData>();
|
||||
|
||||
return <EnvironmentVariablesListPage loaderData={loaderData} />;
|
||||
}
|
||||
|
||||
function EnvironmentVariablesListPage({
|
||||
loaderData,
|
||||
}: {
|
||||
loaderData: EnvironmentVariablesPageLoaderData;
|
||||
}) {
|
||||
const [revealAll, setRevealAll] = useState(false);
|
||||
const { environmentVariables, environments, vercelIntegration } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { environmentVariables, vercelIntegration } = loaderData;
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { value } = useSearchParams();
|
||||
const urlSearch = value("search") ?? "";
|
||||
const { setFilterText, filteredItems } = useFuzzyFilter<EnvironmentVariableWithSetValues>({
|
||||
const { filteredItems } = useFuzzyFilter<EnvironmentVariableWithSetValues>({
|
||||
items: environmentVariables,
|
||||
keys: ["key", "value", "environment.type", "environment.branchName"],
|
||||
filterText: urlSearch,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFilterText(urlSearch);
|
||||
}, [urlSearch, setFilterText]);
|
||||
const tableScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Add isFirst and isLast to each environment variable
|
||||
// They're set based on if they're the first or last time that `key` has been seen in the list
|
||||
const groupedEnvironmentVariables = useMemo(() => {
|
||||
const groupedEnvironmentVariables = useMemo((): GroupedEnvironmentVariable[] => {
|
||||
// Create a map to track occurrences of each key
|
||||
const keyOccurrences = new Map<string, number>();
|
||||
|
||||
@@ -313,6 +354,22 @@ export default function Page() {
|
||||
});
|
||||
}, [filteredItems]);
|
||||
|
||||
const shouldVirtualize = groupedEnvironmentVariables.length > SSR_ROW_WINDOW;
|
||||
const [isVirtualized, setIsVirtualized] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setIsVirtualized(shouldVirtualize);
|
||||
}, [shouldVirtualize]);
|
||||
|
||||
const staticRows = useMemo(() => {
|
||||
if (shouldVirtualize) {
|
||||
return groupedEnvironmentVariables.slice(0, SSR_ROW_WINDOW);
|
||||
}
|
||||
return groupedEnvironmentVariables;
|
||||
}, [groupedEnvironmentVariables, shouldVirtualize]);
|
||||
|
||||
const vercelColumnCount = vercelIntegration?.enabled ? 6 : 5;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
@@ -328,7 +385,7 @@ export default function Page() {
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className={cn("flex h-full flex-col")}>
|
||||
<div className={cn("flex h-full min-h-0 flex-col")}>
|
||||
{environmentVariables.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-2">
|
||||
<SearchInput placeholder="Search variables…" autoFocus />
|
||||
@@ -350,196 +407,285 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Table containerClassName={cn(filteredItems.length === 0 && "border-t-0")}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[22%]" : "w-[25%]"}>
|
||||
Key
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[32%]" : "w-[37%]"}>
|
||||
Value
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[13%]" : "w-[15%]"}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
Environment
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
</span>
|
||||
}
|
||||
content="Dev environment variables specified here will be overridden by ones in your .env file when running locally."
|
||||
className="max-w-60"
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
{vercelIntegration?.enabled && (
|
||||
<TableHeaderCell className="w-[8%]">
|
||||
<div
|
||||
ref={tableScrollRef}
|
||||
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<Table
|
||||
containerClassName={cn(
|
||||
filteredItems.length === 0 && "border-t-0",
|
||||
"overflow-visible"
|
||||
)}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[22%]" : "w-[25%]"}>
|
||||
Key
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[32%]" : "w-[37%]"}>
|
||||
Value
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[13%]" : "w-[15%]"}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
Sync
|
||||
Environment
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
</span>
|
||||
}
|
||||
content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings."
|
||||
content="Dev environment variables specified here will be overridden by ones in your .env file when running locally."
|
||||
className="max-w-60"
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[24%]" : "w-[22%]"}>
|
||||
Updated
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel className="w-0">
|
||||
Actions
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedEnvironmentVariables.length > 0 ? (
|
||||
groupedEnvironmentVariables.map((variable) => {
|
||||
const cellClassName = "py-2";
|
||||
let borderedCellClassName = "";
|
||||
|
||||
if (variable.occurences > 1) {
|
||||
borderedCellClassName =
|
||||
"relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright";
|
||||
if (variable.isLastTime) {
|
||||
borderedCellClassName = "";
|
||||
} else if (variable.isFirstTime) {
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={`${variable.id}-${variable.environment.id}`}
|
||||
className={
|
||||
variable.isLastTime ? "after:bg-charcoal-600" : "after:bg-transparent"
|
||||
}
|
||||
>
|
||||
<TableCell className={cellClassName}>
|
||||
{variable.isFirstTime ? (
|
||||
<CopyableText value={variable.key} className="font-mono" />
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(cellClassName, borderedCellClassName, "after:left-3")}
|
||||
>
|
||||
{variable.isSecret ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="flex items-center gap-x-1">
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
<span className="text-xs text-text-dimmed">Secret</span>
|
||||
</div>
|
||||
}
|
||||
content="This variable is secret and cannot be revealed."
|
||||
/>
|
||||
) : (
|
||||
<ClipboardField
|
||||
secure={!revealAll}
|
||||
value={variable.value}
|
||||
variant={"secondary/small"}
|
||||
fullWidth={true}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
<EnvironmentCombo environment={variable.environment} className="text-sm" />
|
||||
</TableCell>
|
||||
{vercelIntegration?.enabled && (
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
{variable.environment.type !== "DEVELOPMENT" && (
|
||||
<VercelSyncCheckbox
|
||||
envVarKey={variable.key}
|
||||
environmentType={variable.environment.type as TriggerEnvironmentType}
|
||||
syncEnabled={shouldSyncEnvVar(
|
||||
vercelIntegration.syncEnvVarsMapping,
|
||||
variable.key,
|
||||
variable.environment.type as TriggerEnvironmentType
|
||||
)}
|
||||
pullEnvVarsEnabledForEnv={isPullEnvVarsEnabledForEnvironment(
|
||||
vercelIntegration.pullEnvVarsBeforeBuild,
|
||||
variable.environment.type as TriggerEnvironmentType
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{variable.updatedByUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={variable.updatedByUser.avatarUrl}
|
||||
name={variable.updatedByUser.name}
|
||||
className="size-5"
|
||||
/>
|
||||
<span className="text-sm">{variable.updatedByUser.name}</span>
|
||||
</div>
|
||||
) : variable.lastUpdatedBy?.type === "integration" &&
|
||||
variable.lastUpdatedBy?.integration === "vercel" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<VercelLogo className="size-4 text-text-dimmed transition-colors group-hover/table-row:text-text-bright" />
|
||||
<span className="text-sm capitalize text-text-dimmed transition-colors group-hover/table-row:text-text-bright">
|
||||
{variable.lastUpdatedBy.integration}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{variable.updatedAt ? (
|
||||
<span className="text-sm text-text-dimmed">
|
||||
<DateTime date={variable.updatedAt} includeSeconds={false} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
className="[&:has(.group-hover/table-row:block)]:w-auto w-0"
|
||||
hiddenButtons={
|
||||
<>
|
||||
<EditEnvironmentVariablePanel
|
||||
variable={variable}
|
||||
revealAll={revealAll}
|
||||
/>
|
||||
<DeleteEnvironmentVariableButton variable={variable} />
|
||||
</>
|
||||
{vercelIntegration?.enabled && (
|
||||
<TableHeaderCell className="w-[8%]">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
Sync
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
</span>
|
||||
}
|
||||
content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings."
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={vercelIntegration?.enabled ? 6 : 5}>
|
||||
{environmentVariables.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Header2>You haven't set any environment variables yet.</Header2>
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Paragraph>No variables match your search.</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[24%]" : "w-[22%]"}>
|
||||
Updated
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel className="w-0">
|
||||
Actions
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{groupedEnvironmentVariables.length > 0 ? (
|
||||
isVirtualized && shouldVirtualize ? (
|
||||
<EnvironmentVariablesVirtualTableBody
|
||||
groupedEnvironmentVariables={groupedEnvironmentVariables}
|
||||
scrollRef={tableScrollRef}
|
||||
revealAll={revealAll}
|
||||
vercelIntegration={vercelIntegration}
|
||||
columnCount={vercelColumnCount}
|
||||
/>
|
||||
) : (
|
||||
<TableBody>
|
||||
{staticRows.map((variable) => (
|
||||
<EnvironmentVariableTableRow
|
||||
key={`${variable.id}-${variable.environment.id}`}
|
||||
variable={variable}
|
||||
revealAll={revealAll}
|
||||
vercelIntegration={vercelIntegration}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
)
|
||||
) : (
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={vercelColumnCount}>
|
||||
{environmentVariables.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Header2>You haven't set any environment variables yet.</Header2>
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Paragraph>No variables match your search.</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function getBorderedCellClassName(variable: GroupedEnvironmentVariable) {
|
||||
if (variable.occurences <= 1) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (variable.isLastTime) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright";
|
||||
}
|
||||
|
||||
function EnvironmentVariableTableRow({
|
||||
variable,
|
||||
revealAll,
|
||||
vercelIntegration,
|
||||
}: {
|
||||
variable: GroupedEnvironmentVariable;
|
||||
revealAll: boolean;
|
||||
vercelIntegration: PageVercelIntegration | null;
|
||||
}) {
|
||||
const cellClassName = "py-2";
|
||||
const borderedCellClassName = getBorderedCellClassName(variable);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className={variable.isLastTime ? "after:bg-charcoal-600" : "after:bg-transparent"}
|
||||
>
|
||||
<TableCell className={cellClassName}>
|
||||
{variable.isFirstTime ? (
|
||||
<CopyableText value={variable.key} className="font-mono" />
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName, "after:left-3")}>
|
||||
{variable.isSecret ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="flex items-center gap-x-1">
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
<span className="text-xs text-text-dimmed">Secret</span>
|
||||
</div>
|
||||
}
|
||||
content="This variable is secret and cannot be revealed."
|
||||
/>
|
||||
) : (
|
||||
<ClipboardField
|
||||
secure={!revealAll}
|
||||
value={variable.value}
|
||||
variant={"secondary/small"}
|
||||
fullWidth={true}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
<EnvironmentCombo environment={variable.environment} className="text-sm" />
|
||||
</TableCell>
|
||||
{vercelIntegration?.enabled && (
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
{variable.environment.type !== "DEVELOPMENT" && (
|
||||
<VercelSyncCheckbox
|
||||
envVarKey={variable.key}
|
||||
environmentType={variable.environment.type as TriggerEnvironmentType}
|
||||
syncEnabled={shouldSyncEnvVar(
|
||||
vercelIntegration.syncEnvVarsMapping,
|
||||
variable.key,
|
||||
variable.environment.type as TriggerEnvironmentType
|
||||
)}
|
||||
pullEnvVarsEnabledForEnv={isPullEnvVarsEnabledForEnvironment(
|
||||
vercelIntegration.pullEnvVarsBeforeBuild,
|
||||
variable.environment.type as TriggerEnvironmentType
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className={cn(cellClassName, borderedCellClassName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{variable.updatedAt ? (
|
||||
<span className="shrink-0 text-sm tabular-nums text-text-dimmed">
|
||||
<DateTime date={variable.updatedAt} includeSeconds={false} />
|
||||
</span>
|
||||
) : null}
|
||||
{variable.updatedByUser ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={variable.updatedByUser.avatarUrl}
|
||||
name={variable.updatedByUser.name}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
<span className="truncate text-sm">{variable.updatedByUser.name}</span>
|
||||
</div>
|
||||
) : variable.lastUpdatedBy?.type === "integration" &&
|
||||
variable.lastUpdatedBy?.integration === "vercel" ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<VercelLogo className="size-4 shrink-0 text-text-dimmed transition-colors group-hover/table-row:text-text-bright" />
|
||||
<span className="truncate text-sm capitalize text-text-dimmed transition-colors group-hover/table-row:text-text-bright">
|
||||
{variable.lastUpdatedBy.integration}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
className="[&:has(.group-hover/table-row:block)]:w-auto w-0"
|
||||
hiddenButtons={
|
||||
<>
|
||||
<EditEnvironmentVariablePanel variable={variable} revealAll={revealAll} />
|
||||
<DeleteEnvironmentVariableButton variable={variable} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentVariablesVirtualTableBody({
|
||||
groupedEnvironmentVariables,
|
||||
scrollRef,
|
||||
revealAll,
|
||||
vercelIntegration,
|
||||
columnCount,
|
||||
}: {
|
||||
groupedEnvironmentVariables: GroupedEnvironmentVariable[];
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
revealAll: boolean;
|
||||
vercelIntegration: PageVercelIntegration | null;
|
||||
columnCount: number;
|
||||
}) {
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: groupedEnvironmentVariables.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_ESTIMATE_HEIGHT,
|
||||
overscan: VIRTUAL_OVERSCAN,
|
||||
});
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const topSpacerHeight = virtualItems[0]?.start ?? 0;
|
||||
const bottomSpacerHeight =
|
||||
rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
|
||||
|
||||
return (
|
||||
<TableBody>
|
||||
{topSpacerHeight > 0 && (
|
||||
<tr aria-hidden style={{ height: topSpacerHeight }}>
|
||||
<td colSpan={columnCount} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const variable = groupedEnvironmentVariables[virtualRow.index];
|
||||
if (!variable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentVariableTableRow
|
||||
key={`${variable.id}-${variable.environment.id}`}
|
||||
variable={variable}
|
||||
revealAll={revealAll}
|
||||
vercelIntegration={vercelIntegration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{bottomSpacerHeight > 0 && (
|
||||
<tr aria-hidden style={{ height: bottomSpacerHeight }}>
|
||||
<td colSpan={columnCount} />
|
||||
</tr>
|
||||
)}
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
function EditEnvironmentVariablePanel({
|
||||
variable,
|
||||
revealAll,
|
||||
|
||||
@@ -18,6 +18,7 @@ type ProviderInitializationOptions = {
|
||||
export interface SecretStoreProvider {
|
||||
getSecret<T>(schema: z.Schema<T>, key: string): Promise<T | undefined>;
|
||||
getSecrets<T>(schema: z.Schema<T>, keyPrefix: string): Promise<{ key: string; value: T }[]>;
|
||||
getSecretsByKeys<T>(schema: z.Schema<T>, keys: string[]): Promise<{ key: string; value: T }[]>;
|
||||
setSecret<T extends object>(key: string, value: T): Promise<void>;
|
||||
deleteSecret(key: string): Promise<void>;
|
||||
}
|
||||
@@ -48,6 +49,10 @@ export class SecretStore {
|
||||
return this.provider.getSecrets(schema, keyPrefix);
|
||||
}
|
||||
|
||||
getSecretsByKeys<T>(schema: z.Schema<T>, keys: string[]): Promise<{ key: string; value: T }[]> {
|
||||
return this.provider.getSecretsByKeys(schema, keys);
|
||||
}
|
||||
|
||||
deleteSecret(key: string): Promise<void> {
|
||||
return this.provider.deleteSecret(key);
|
||||
}
|
||||
@@ -83,29 +88,7 @@ class PrismaSecretStore implements SecretStoreProvider {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (secret.version === "1") {
|
||||
return schema.parse(secret.value);
|
||||
}
|
||||
|
||||
const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value);
|
||||
|
||||
if (!encryptedData.success) {
|
||||
throw new Error(`Unable to parse encrypted secret ${key}: ${encryptedData.error.message}`);
|
||||
}
|
||||
|
||||
const decrypted = await this.#decrypt(
|
||||
encryptedData.data.nonce,
|
||||
encryptedData.data.ciphertext,
|
||||
encryptedData.data.tag
|
||||
);
|
||||
|
||||
const parsedDecrypted = safeJsonParse(decrypted);
|
||||
|
||||
if (!parsedDecrypted) {
|
||||
return;
|
||||
}
|
||||
|
||||
return schema.parse(parsedDecrypted);
|
||||
return this.#parseStoredSecret(schema, secret);
|
||||
}
|
||||
|
||||
async getSecrets<T>(
|
||||
@@ -120,39 +103,76 @@ class PrismaSecretStore implements SecretStoreProvider {
|
||||
},
|
||||
});
|
||||
|
||||
return this.#parseStoredSecrets(schema, secrets);
|
||||
}
|
||||
|
||||
async getSecretsByKeys<T>(
|
||||
schema: z.Schema<T>,
|
||||
keys: string[]
|
||||
): Promise<{ key: string; value: T }[]> {
|
||||
if (keys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const secrets = await this.#prismaClient.secretStore.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: keys,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.#parseStoredSecrets(schema, secrets);
|
||||
}
|
||||
|
||||
async #parseStoredSecrets<T>(
|
||||
schema: z.Schema<T>,
|
||||
secrets: Array<{ key: string; value: unknown; version: string }>
|
||||
): Promise<{ key: string; value: T }[]> {
|
||||
const results = [] as { key: string; value: T }[];
|
||||
|
||||
for (const secret of secrets) {
|
||||
if (secret.version === "1") {
|
||||
results.push({ key: secret.key, value: schema.parse(secret.value) });
|
||||
const value = await this.#parseStoredSecret(schema, secret);
|
||||
if (value !== undefined) {
|
||||
results.push({ key: secret.key, value });
|
||||
}
|
||||
|
||||
const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value);
|
||||
|
||||
if (!encryptedData.success) {
|
||||
throw new Error(
|
||||
`Unable to parse encrypted secret ${secret.key}: ${encryptedData.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const decrypted = await this.#decrypt(
|
||||
encryptedData.data.nonce,
|
||||
encryptedData.data.ciphertext,
|
||||
encryptedData.data.tag
|
||||
);
|
||||
|
||||
const parsedDecrypted = safeJsonParse(decrypted);
|
||||
if (!parsedDecrypted) {
|
||||
logger.error(`Secret isn't JSON ${secret.key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ key: secret.key, value: schema.parse(parsedDecrypted) });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async #parseStoredSecret<T>(
|
||||
schema: z.Schema<T>,
|
||||
secret: { key: string; value: unknown; version: string }
|
||||
): Promise<T | undefined> {
|
||||
if (secret.version === "1") {
|
||||
return schema.parse(secret.value);
|
||||
}
|
||||
|
||||
const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value);
|
||||
|
||||
if (!encryptedData.success) {
|
||||
throw new Error(
|
||||
`Unable to parse encrypted secret ${secret.key}: ${encryptedData.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const decrypted = await this.#decrypt(
|
||||
encryptedData.data.nonce,
|
||||
encryptedData.data.ciphertext,
|
||||
encryptedData.data.tag
|
||||
);
|
||||
|
||||
const parsedDecrypted = safeJsonParse(decrypted);
|
||||
|
||||
if (!parsedDecrypted) {
|
||||
logger.error(`Secret isn't JSON ${secret.key}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return schema.parse(parsedDecrypted);
|
||||
}
|
||||
|
||||
async setSecret<T extends object>(key: string, value: T): Promise<void> {
|
||||
const encrypted = await this.#encrypt(JSON.stringify(value));
|
||||
|
||||
|
||||
@@ -577,6 +577,42 @@ export class EnvironmentVariablesRepository implements Repository {
|
||||
return results;
|
||||
}
|
||||
|
||||
async getVariableValuesForKeys(
|
||||
projectId: string,
|
||||
items: Array<{ environmentId: string; key: string }>
|
||||
): Promise<Map<string, string>> {
|
||||
if (items.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const uniqueItems = new Map<string, { environmentId: string; key: string }>();
|
||||
for (const item of items) {
|
||||
uniqueItems.set(`${item.environmentId}:${item.key}`, item);
|
||||
}
|
||||
|
||||
const secretStore = getSecretStore("DATABASE", {
|
||||
prismaClient: this.prismaClient,
|
||||
});
|
||||
|
||||
const storeKeys = Array.from(uniqueItems.values()).map((item) =>
|
||||
secretKey(projectId, item.environmentId, item.key)
|
||||
);
|
||||
|
||||
const secrets = await secretStore.getSecretsByKeys(SecretValue, storeKeys);
|
||||
const secretsByStoreKey = new Map(secrets.map((secret) => [secret.key, secret.value.secret]));
|
||||
|
||||
const values = new Map<string, string>();
|
||||
for (const item of uniqueItems.values()) {
|
||||
const storeKey = secretKey(projectId, item.environmentId, item.key);
|
||||
const value = secretsByStoreKey.get(storeKey);
|
||||
if (value !== undefined) {
|
||||
values.set(`${item.environmentId}:${item.key}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
async getEnvironmentWithRedactedSecrets(
|
||||
projectId: string,
|
||||
environmentId: string,
|
||||
|
||||
@@ -106,6 +106,14 @@ export interface Repository {
|
||||
edit(projectId: string, options: EditEnvironmentVariable): Promise<Result>;
|
||||
editValue(projectId: string, options: EditEnvironmentVariableValue): Promise<Result>;
|
||||
getProject(projectId: string): Promise<ProjectEnvironmentVariable[]>;
|
||||
/**
|
||||
* Fetch and decrypt only the given env var values (for dashboard display of non-secret rows).
|
||||
* Map keys are `${environmentId}:${variableKey}`.
|
||||
*/
|
||||
getVariableValuesForKeys(
|
||||
projectId: string,
|
||||
items: Array<{ environmentId: string; key: string }>
|
||||
): Promise<Map<string, string>>;
|
||||
/**
|
||||
* Get the environment variables for a given environment, it does NOT return values for secret variables
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
$transaction: async (
|
||||
prismaClient: {
|
||||
$transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>;
|
||||
},
|
||||
nameOrFn: string | ((tx: unknown) => Promise<unknown>),
|
||||
fnOrOptions?: ((tx: unknown) => Promise<unknown>) | unknown
|
||||
) => {
|
||||
const fn =
|
||||
typeof nameOrFn === "string"
|
||||
? (fnOrOptions as (tx: unknown) => Promise<unknown>)
|
||||
: nameOrFn;
|
||||
|
||||
return prismaClient.$transaction(fn);
|
||||
},
|
||||
}));
|
||||
|
||||
import { postgresTest } from "@internal/testcontainers";
|
||||
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import {
|
||||
createEnvironmentVariable,
|
||||
createRuntimeEnvironment,
|
||||
createTestOrgProjectWithMember,
|
||||
} from "./fixtures/environmentVariablesFixtures";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
describe("EnvironmentVariablesPresenter", () => {
|
||||
postgresTest("keeps secret values redacted while returning non-secret values", async ({ prisma }) => {
|
||||
const { user, organization, project, projectSlug } = await createTestOrgProjectWithMember(prisma);
|
||||
const production = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(prisma, prisma);
|
||||
|
||||
await createEnvironmentVariable(repository, project.id, {
|
||||
environmentId: production.id,
|
||||
key: "SECRET_VAR",
|
||||
value: "super-secret",
|
||||
isSecret: true,
|
||||
userId: user.id,
|
||||
});
|
||||
await createEnvironmentVariable(repository, project.id, {
|
||||
environmentId: production.id,
|
||||
key: "PLAIN_VAR",
|
||||
value: "plain-value",
|
||||
isSecret: false,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const result = await new EnvironmentVariablesPresenter(prisma).call({
|
||||
userId: user.id,
|
||||
projectSlug,
|
||||
});
|
||||
|
||||
const secretVariable = result.environmentVariables.find((variable) => variable.key === "SECRET_VAR");
|
||||
const nonSecretVariable = result.environmentVariables.find((variable) => variable.key === "PLAIN_VAR");
|
||||
|
||||
expect(secretVariable).toBeDefined();
|
||||
expect(nonSecretVariable).toBeDefined();
|
||||
expect(secretVariable!.value).toBe("");
|
||||
expect(nonSecretVariable!.value).toBe("plain-value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
$transaction: async (
|
||||
prismaClient: {
|
||||
$transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>;
|
||||
},
|
||||
nameOrFn: string | ((tx: unknown) => Promise<unknown>),
|
||||
fnOrOptions?: ((tx: unknown) => Promise<unknown>) | unknown
|
||||
) => {
|
||||
const fn =
|
||||
typeof nameOrFn === "string"
|
||||
? (fnOrOptions as (tx: unknown) => Promise<unknown>)
|
||||
: nameOrFn;
|
||||
|
||||
return prismaClient.$transaction(fn);
|
||||
},
|
||||
}));
|
||||
|
||||
import { postgresTest } from "@internal/testcontainers";
|
||||
import { loadEnvironmentVariablesEnvironments } from "~/presenters/v3/environmentVariablesEnvironments.server";
|
||||
import {
|
||||
createRuntimeEnvironment,
|
||||
createTestOrgProjectWithMember,
|
||||
createTestUser,
|
||||
} from "./fixtures/environmentVariablesFixtures";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
describe("loadEnvironmentVariablesEnvironments", () => {
|
||||
postgresTest("returns environments for a project member", async ({ prisma }) => {
|
||||
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
|
||||
|
||||
const production = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
|
||||
const result = await loadEnvironmentVariablesEnvironments(prisma, {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
expect(result.environments.map((environment) => environment.id)).toContain(production.id);
|
||||
expect(result.environments.every((environment) => typeof environment.id === "string")).toBe(true);
|
||||
});
|
||||
|
||||
postgresTest("rejects users who are not project members", async ({ prisma }) => {
|
||||
const { project } = await createTestOrgProjectWithMember(prisma);
|
||||
const outsider = await createTestUser(prisma);
|
||||
|
||||
await expect(
|
||||
loadEnvironmentVariablesEnvironments(prisma, {
|
||||
userId: outsider.id,
|
||||
projectId: project.id,
|
||||
})
|
||||
).rejects.toThrow("Project not found");
|
||||
});
|
||||
|
||||
postgresTest("filters shared, personal, and inaccessible environments", async ({ prisma }) => {
|
||||
const { user, organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
|
||||
const otherUser = await createTestUser(prisma);
|
||||
const otherOrgMember = await prisma.orgMember.create({
|
||||
data: {
|
||||
organizationId: organization.id,
|
||||
userId: otherUser.id,
|
||||
role: "MEMBER",
|
||||
},
|
||||
});
|
||||
|
||||
const sharedProduction = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
const currentUserDev = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "DEVELOPMENT",
|
||||
orgMemberId: orgMember.id,
|
||||
});
|
||||
const otherUserDev = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "DEVELOPMENT",
|
||||
orgMemberId: otherOrgMember.id,
|
||||
});
|
||||
const orphanedDev = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "DEVELOPMENT",
|
||||
orgMemberId: null,
|
||||
});
|
||||
|
||||
const result = await loadEnvironmentVariablesEnvironments(prisma, {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const environmentIds = result.environments.map((environment) => environment.id);
|
||||
|
||||
expect(environmentIds).toContain(sharedProduction.id);
|
||||
expect(environmentIds).toContain(currentUserDev.id);
|
||||
expect(environmentIds).not.toContain(otherUserDev.id);
|
||||
expect(environmentIds).not.toContain(orphanedDev.id);
|
||||
});
|
||||
|
||||
postgresTest("returns hasStaging true when a staging environment exists", async ({ prisma }) => {
|
||||
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
|
||||
|
||||
await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "STAGING",
|
||||
});
|
||||
|
||||
const result = await loadEnvironmentVariablesEnvironments(prisma, {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
expect(result.hasStaging).toBe(true);
|
||||
});
|
||||
|
||||
postgresTest("returns hasStaging false when no staging environment exists", async ({ prisma }) => {
|
||||
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
|
||||
|
||||
await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
|
||||
const result = await loadEnvironmentVariablesEnvironments(prisma, {
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
expect(result.hasStaging).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
$transaction: async (
|
||||
prismaClient: {
|
||||
$transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>;
|
||||
},
|
||||
nameOrFn: string | ((tx: unknown) => Promise<unknown>),
|
||||
fnOrOptions?: ((tx: unknown) => Promise<unknown>) | unknown
|
||||
) => {
|
||||
const fn =
|
||||
typeof nameOrFn === "string"
|
||||
? (fnOrOptions as (tx: unknown) => Promise<unknown>)
|
||||
: nameOrFn;
|
||||
|
||||
return prismaClient.$transaction(fn);
|
||||
},
|
||||
}));
|
||||
|
||||
import { postgresTest } from "@internal/testcontainers";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import {
|
||||
createEnvironmentVariable,
|
||||
createRuntimeEnvironment,
|
||||
createTestOrgProjectWithMember,
|
||||
} from "./fixtures/environmentVariablesFixtures";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
describe("EnvironmentVariablesRepository.getVariableValuesForKeys", () => {
|
||||
postgresTest("returns an empty map for an empty items array", async ({ prisma }) => {
|
||||
const { project } = await createTestOrgProjectWithMember(prisma);
|
||||
const repository = new EnvironmentVariablesRepository(prisma, prisma);
|
||||
|
||||
const result = await repository.getVariableValuesForKeys(project.id, []);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
postgresTest("omits missing keys from the result without throwing", async ({ prisma }) => {
|
||||
const { organization, project } = await createTestOrgProjectWithMember(prisma);
|
||||
const environment = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(prisma, prisma);
|
||||
|
||||
const result = await repository.getVariableValuesForKeys(project.id, [
|
||||
{ environmentId: environment.id, key: "DOES_NOT_EXIST" },
|
||||
]);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(result.has(`${environment.id}:DOES_NOT_EXIST`)).toBe(false);
|
||||
});
|
||||
|
||||
postgresTest("returns requested values with correct map keys and decrypted values", async ({ prisma }) => {
|
||||
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
|
||||
const environment = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(prisma, prisma);
|
||||
|
||||
await createEnvironmentVariable(repository, project.id, {
|
||||
environmentId: environment.id,
|
||||
key: "VAR_A",
|
||||
value: "value-a",
|
||||
userId: user.id,
|
||||
});
|
||||
await createEnvironmentVariable(repository, project.id, {
|
||||
environmentId: environment.id,
|
||||
key: "VAR_B",
|
||||
value: "value-b",
|
||||
userId: user.id,
|
||||
});
|
||||
await createEnvironmentVariable(repository, project.id, {
|
||||
environmentId: environment.id,
|
||||
key: "VAR_C",
|
||||
value: "value-c",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const result = await repository.getVariableValuesForKeys(project.id, [
|
||||
{ environmentId: environment.id, key: "VAR_A" },
|
||||
{ environmentId: environment.id, key: "VAR_C" },
|
||||
]);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(`${environment.id}:VAR_A`)).toBe("value-a");
|
||||
expect(result.get(`${environment.id}:VAR_C`)).toBe("value-c");
|
||||
expect(result.has(`${environment.id}:VAR_B`)).toBe(false);
|
||||
});
|
||||
|
||||
postgresTest("deduplicates duplicate environmentId and key requests", async ({ prisma }) => {
|
||||
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
|
||||
const environment = await createRuntimeEnvironment(prisma, {
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(prisma, prisma);
|
||||
|
||||
await createEnvironmentVariable(repository, project.id, {
|
||||
environmentId: environment.id,
|
||||
key: "DEDUP_KEY",
|
||||
value: "dedup-value",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const request = { environmentId: environment.id, key: "DEDUP_KEY" };
|
||||
const result = await repository.getVariableValuesForKeys(project.id, [request, request, request]);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(`${environment.id}:DEDUP_KEY`)).toBe("dedup-value");
|
||||
});
|
||||
|
||||
postgresTest("isolates values by project", async ({ prisma }) => {
|
||||
const { user, organization, project: projectA } = await createTestOrgProjectWithMember(prisma);
|
||||
|
||||
const projectB = await prisma.project.create({
|
||||
data: {
|
||||
name: "Project B",
|
||||
slug: `proj-b-${Date.now()}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `ext-b-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const envA = await createRuntimeEnvironment(prisma, {
|
||||
projectId: projectA.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
const envB = await createRuntimeEnvironment(prisma, {
|
||||
projectId: projectB.id,
|
||||
organizationId: organization.id,
|
||||
type: "PRODUCTION",
|
||||
});
|
||||
|
||||
const repository = new EnvironmentVariablesRepository(prisma, prisma);
|
||||
|
||||
await createEnvironmentVariable(repository, projectA.id, {
|
||||
environmentId: envA.id,
|
||||
key: "SHARED_KEY",
|
||||
value: "project-a-value",
|
||||
userId: user.id,
|
||||
});
|
||||
await createEnvironmentVariable(repository, projectB.id, {
|
||||
environmentId: envB.id,
|
||||
key: "SHARED_KEY",
|
||||
value: "project-b-value",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const resultForProjectA = await repository.getVariableValuesForKeys(projectA.id, [
|
||||
{ environmentId: envA.id, key: "SHARED_KEY" },
|
||||
]);
|
||||
|
||||
expect(resultForProjectA.size).toBe(1);
|
||||
expect(resultForProjectA.get(`${envA.id}:SHARED_KEY`)).toBe("project-a-value");
|
||||
expect(resultForProjectA.get(`${envB.id}:SHARED_KEY`)).toBeUndefined();
|
||||
|
||||
const crossProjectRequest = await repository.getVariableValuesForKeys(projectA.id, [
|
||||
{ environmentId: envB.id, key: "SHARED_KEY" },
|
||||
]);
|
||||
|
||||
expect(crossProjectRequest.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { PrismaClient, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export function uniqueId(prefix: string) {
|
||||
idCounter += 1;
|
||||
return `${prefix}-${idCounter}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export async function createTestUser(prisma: PrismaClient, email?: string) {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email: email ?? `${uniqueId("user")}@test.com`,
|
||||
authenticationMethod: "MAGIC_LINK",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTestOrgProjectWithMember(
|
||||
prisma: PrismaClient,
|
||||
options?: { userId?: string }
|
||||
) {
|
||||
const user = options?.userId
|
||||
? await prisma.user.findUniqueOrThrow({ where: { id: options.userId } })
|
||||
: await createTestUser(prisma);
|
||||
|
||||
const orgSlug = uniqueId("org");
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title: "Test Org",
|
||||
slug: orgSlug,
|
||||
members: { create: { userId: user.id, role: "ADMIN" } },
|
||||
},
|
||||
include: { members: true },
|
||||
});
|
||||
|
||||
const projectSlug = uniqueId("proj");
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "Test Project",
|
||||
slug: projectSlug,
|
||||
organizationId: organization.id,
|
||||
externalRef: uniqueId("ext"),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
organization,
|
||||
project,
|
||||
orgMember: organization.members[0]!,
|
||||
projectSlug,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRuntimeEnvironment(
|
||||
prisma: PrismaClient,
|
||||
options: {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
orgMemberId?: string | null;
|
||||
slug?: string;
|
||||
}
|
||||
) {
|
||||
const slug = options.slug ?? uniqueId("env");
|
||||
return prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug,
|
||||
type: options.type,
|
||||
projectId: options.projectId,
|
||||
organizationId: options.organizationId,
|
||||
orgMemberId: options.orgMemberId ?? null,
|
||||
apiKey: uniqueId("api"),
|
||||
pkApiKey: uniqueId("pk"),
|
||||
shortcode: uniqueId("sc"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createEnvironmentVariable(
|
||||
repository: EnvironmentVariablesRepository,
|
||||
projectId: string,
|
||||
options: {
|
||||
environmentId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
isSecret?: boolean;
|
||||
userId: string;
|
||||
}
|
||||
) {
|
||||
const result = await repository.create(projectId, {
|
||||
override: true,
|
||||
environmentIds: [options.environmentId],
|
||||
variables: [{ key: options.key, value: options.value }],
|
||||
isSecret: options.isSecret ?? false,
|
||||
lastUpdatedBy: { type: "user", userId: options.userId },
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
}
|
||||
@@ -523,7 +523,13 @@ class ExternalLogRecordExporterWrapper {
|
||||
}
|
||||
|
||||
forceFlush(): Promise<void> {
|
||||
return this.underlyingExporter.forceFlush?.() ?? Promise.resolve();
|
||||
const underlyingExporter = this.underlyingExporter as LogRecordExporter & {
|
||||
forceFlush?: () => Promise<void>;
|
||||
};
|
||||
|
||||
return underlyingExporter.forceFlush
|
||||
? underlyingExporter.forceFlush()
|
||||
: Promise.resolve();
|
||||
}
|
||||
|
||||
transformLogRecord(
|
||||
|
||||
Reference in New Issue
Block a user