Skip to content

Commit f4ce428

Browse files
committed
Early draft of the concurrency page
1 parent dde3c00 commit f4ce428

File tree

10 files changed

+412
-27
lines changed

10 files changed

+412
-27
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function ConcurrencyIcon({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
4+
<circle cx="3.75" cy="3.75" r="2.25" fill="currentColor" />
5+
<circle cx="9" cy="3.75" r="2.25" fill="currentColor" />
6+
<circle cx="14.25" cy="3.75" r="2.25" fill="currentColor" />
7+
<circle cx="3.75" cy="9" r="2.25" fill="currentColor" />
8+
<circle cx="9" cy="9" r="2.25" fill="currentColor" />
9+
<circle cx="9" cy="14.25" r="1.75" stroke="currentColor" />
10+
<circle cx="14.25" cy="9" r="2.25" fill="currentColor" />
11+
</svg>
12+
);
13+
}

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { Link, useNavigation } from "@remix-run/react";
2424
import { useEffect, useRef, useState, type ReactNode } from "react";
2525
import simplur from "simplur";
2626
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
27+
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
2728
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
2829
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
2930
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
@@ -43,6 +44,7 @@ import {
4344
accountPath,
4445
adminPath,
4546
branchesPath,
47+
concurrencyPath,
4648
logoutPath,
4749
newOrganizationPath,
4850
newProjectPath,
@@ -122,6 +124,7 @@ export function SideMenu({
122124
const { isConnected } = useDevPresence();
123125
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
124126
const isAdmin = useHasAdminAccess();
127+
const { isManagedCloud } = useFeatures();
125128

126129
useEffect(() => {
127130
const handleScroll = () => {
@@ -313,6 +316,15 @@ export function SideMenu({
313316
data-action="preview-branches"
314317
badge={<V4Badge />}
315318
/>
319+
{isManagedCloud && (
320+
<SideMenuItem
321+
name="Concurrency"
322+
icon={ConcurrencyIcon}
323+
activeIconColor="text-amber-500"
324+
to={concurrencyPath(organization, project, environment)}
325+
data-action="concurrency"
326+
/>
327+
)}
316328
<SideMenuItem
317329
name="Regions"
318330
icon={GlobeAmericasIcon}

apps/webapp/app/models/organization.server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { prisma, type PrismaClientOrTransaction } from "~/db.server";
1212
import { env } from "~/env.server";
1313
import { featuresForUrl } from "~/features.server";
1414
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
15-
15+
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
1616
export type { Organization };
1717

1818
const nanoid = customAlphabet("1234567890abcdef", 4);
@@ -96,14 +96,16 @@ export async function createEnvironment({
9696
const pkApiKey = createPkApiKeyForEnv(type);
9797
const shortcode = createShortcode().join("-");
9898

99+
const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type);
100+
99101
return await prismaClient.runtimeEnvironment.create({
100102
data: {
101103
slug,
102104
apiKey,
103105
pkApiKey,
104106
shortcode,
105107
autoEnableInternalSources: type !== "DEVELOPMENT",
106-
maximumConcurrencyLimit: organization.maximumConcurrencyLimit / 3,
108+
maximumConcurrencyLimit: limit,
107109
organization: {
108110
connect: {
109111
id: organization.id,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
2+
import { getCurrentPlan, getDefaultEnvironmentLimitFromPlan } from "~/services/platform.v3.server";
3+
import { BasePresenter } from "./basePresenter.server";
4+
import { sortEnvironments } from "~/utils/environmentSort";
5+
6+
export type ConcurrencyResult = {
7+
canAddConcurrency: boolean;
8+
environments: EnvironmentWithConcurrency[];
9+
extraConcurrency: number;
10+
extraAllocatedConcurrency: number;
11+
};
12+
13+
export type EnvironmentWithConcurrency = {
14+
id: string;
15+
type: RuntimeEnvironmentType;
16+
isBranchableEnvironment: boolean;
17+
branchName: string | null;
18+
parentEnvironmentId: string | null;
19+
maximumConcurrencyLimit: number;
20+
planConcurrencyLimit: number;
21+
};
22+
23+
export class ManageConcurrencyPresenter extends BasePresenter {
24+
public async call({
25+
userId,
26+
projectId,
27+
organizationId,
28+
}: {
29+
userId: string;
30+
projectId: string;
31+
organizationId: string;
32+
}): Promise<ConcurrencyResult> {
33+
// Get plan
34+
const currentPlan = await getCurrentPlan(organizationId);
35+
if (!currentPlan) {
36+
throw new Error("No plan found");
37+
}
38+
39+
const canAddConcurrency =
40+
currentPlan.v3Subscription.plan?.limits.concurrentRuns.canExceed === true;
41+
42+
const environments = await this._replica.runtimeEnvironment.findMany({
43+
select: {
44+
id: true,
45+
projectId: true,
46+
type: true,
47+
branchName: true,
48+
parentEnvironmentId: true,
49+
isBranchableEnvironment: true,
50+
maximumConcurrencyLimit: true,
51+
},
52+
where: {
53+
organizationId,
54+
},
55+
});
56+
57+
const extraConcurrency = currentPlan?.v3Subscription.addOns?.concurrentRuns?.purchased ?? 0;
58+
59+
// Go through all environments and add up extra concurrency above their allowed allocation
60+
let extraAllocatedConcurrency = 0;
61+
const projectEnvironments: EnvironmentWithConcurrency[] = [];
62+
for (const environment of environments) {
63+
// Don't count parent environments
64+
if (environment.isBranchableEnvironment) continue;
65+
66+
const limit = currentPlan
67+
? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan)
68+
: 0;
69+
if (!limit) continue;
70+
71+
if (environment.maximumConcurrencyLimit > limit) {
72+
extraAllocatedConcurrency += environment.maximumConcurrencyLimit - limit;
73+
}
74+
75+
if (environment.projectId === projectId) {
76+
projectEnvironments.push({
77+
id: environment.id,
78+
type: environment.type,
79+
isBranchableEnvironment: environment.isBranchableEnvironment,
80+
branchName: environment.branchName,
81+
parentEnvironmentId: environment.parentEnvironmentId,
82+
maximumConcurrencyLimit: environment.maximumConcurrencyLimit,
83+
planConcurrencyLimit: limit,
84+
});
85+
}
86+
}
87+
88+
return {
89+
canAddConcurrency,
90+
extraConcurrency,
91+
extraAllocatedConcurrency,
92+
environments: sortEnvironments(projectEnvironments).reverse(),
93+
};
94+
}
95+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { MetaFunction } from "@remix-run/react";
2+
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
3+
import { tryCatch } from "@trigger.dev/core";
4+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
5+
import { z } from "zod";
6+
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
7+
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
8+
import {
9+
MainCenteredContainer,
10+
MainHorizontallyCenteredContainer,
11+
PageBody,
12+
PageContainer,
13+
} from "~/components/layout/AppLayout";
14+
import { Header2 } from "~/components/primitives/Headers";
15+
import { InputGroup } from "~/components/primitives/InputGroup";
16+
import { Label } from "~/components/primitives/Label";
17+
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
18+
import { Paragraph } from "~/components/primitives/Paragraph";
19+
import * as Property from "~/components/primitives/PropertyTable";
20+
import {
21+
Table,
22+
TableBody,
23+
TableCell,
24+
TableHeader,
25+
TableHeaderCell,
26+
TableRow,
27+
} from "~/components/primitives/Table";
28+
import { useOrganization } from "~/hooks/useOrganizations";
29+
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
30+
import { findProjectBySlug } from "~/models/project.server";
31+
import {
32+
EnvironmentWithConcurrency,
33+
ManageConcurrencyPresenter,
34+
} from "~/presenters/v3/ManageConcurrencyPresenter.server";
35+
import { requireUser, requireUserId } from "~/services/session.server";
36+
import { cn } from "~/utils/cn";
37+
import { EnvironmentParamSchema, regionsPath, v3BillingPath } from "~/utils/pathBuilder";
38+
import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server";
39+
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
40+
import { useFeatures } from "~/hooks/useFeatures";
41+
import { LinkButton } from "~/components/primitives/Buttons";
42+
43+
export const meta: MetaFunction = () => {
44+
return [
45+
{
46+
title: `Concurrency | Trigger.dev`,
47+
},
48+
];
49+
};
50+
51+
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
52+
const userId = await requireUserId(request);
53+
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
54+
55+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
56+
if (!project) {
57+
throw new Response(undefined, {
58+
status: 404,
59+
statusText: "Project not found",
60+
});
61+
}
62+
63+
const presenter = new ManageConcurrencyPresenter();
64+
const [error, result] = await tryCatch(
65+
presenter.call({
66+
userId: userId,
67+
projectId: project.id,
68+
organizationId: project.organizationId,
69+
})
70+
);
71+
72+
if (error) {
73+
throw new Response(undefined, {
74+
status: 400,
75+
statusText: error.message,
76+
});
77+
}
78+
79+
return typedjson(result);
80+
};
81+
82+
const FormSchema = z.object({
83+
regionId: z.string(),
84+
});
85+
86+
export const action = async ({ request, params }: ActionFunctionArgs) => {
87+
const user = await requireUser(request);
88+
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
89+
90+
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
91+
92+
const redirectPath = regionsPath(
93+
{ slug: organizationSlug },
94+
{ slug: projectParam },
95+
{ slug: envParam }
96+
);
97+
98+
if (!project) {
99+
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
100+
}
101+
102+
const formData = await request.formData();
103+
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
104+
105+
if (!parsedFormData.success) {
106+
throw redirectWithErrorMessage(redirectPath, request, "No region specified");
107+
}
108+
109+
const service = new SetDefaultRegionService();
110+
const [error, result] = await tryCatch(
111+
service.call({
112+
projectId: project.id,
113+
regionId: parsedFormData.data.regionId,
114+
isAdmin: user.admin || user.isImpersonating,
115+
})
116+
);
117+
118+
if (error) {
119+
return redirectWithErrorMessage(redirectPath, request, error.message);
120+
}
121+
122+
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
123+
};
124+
125+
export default function Page() {
126+
const { canAddConcurrency, environments } = useTypedLoaderData<typeof loader>();
127+
const organization = useOrganization();
128+
129+
return (
130+
<PageContainer>
131+
<NavBar>
132+
<PageTitle title="Concurrency" />
133+
<PageAccessories>
134+
<AdminDebugTooltip>
135+
<Property.Table>
136+
{environments.map((environment) => (
137+
<Property.Item key={environment.id}>
138+
<Property.Label>
139+
{environment.type}{" "}
140+
{environment.branchName ? ` (${environment.branchName})` : ""}
141+
</Property.Label>
142+
<Property.Value>{environment.id}</Property.Value>
143+
</Property.Item>
144+
))}
145+
</Property.Table>
146+
</AdminDebugTooltip>
147+
</PageAccessories>
148+
</NavBar>
149+
<PageBody scrollable={false}>
150+
<MainHorizontallyCenteredContainer>
151+
{canAddConcurrency ? (
152+
<div>
153+
<div className="mb-3 border-b border-grid-dimmed pb-1">
154+
<Header2>Manage your concurrency</Header2>
155+
</div>
156+
<div className="flex flex-col gap-6">
157+
<InputGroup fullWidth>
158+
<div className="flex w-full items-center justify-between">
159+
<Label>Secret key</Label>
160+
</div>
161+
</InputGroup>
162+
</div>
163+
</div>
164+
) : (
165+
<NotUpgradable environments={environments} />
166+
)}
167+
</MainHorizontallyCenteredContainer>
168+
</PageBody>
169+
</PageContainer>
170+
);
171+
}
172+
173+
function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) {
174+
const { isManagedCloud } = useFeatures();
175+
const plan = useCurrentPlan();
176+
const organization = useOrganization();
177+
178+
return (
179+
<div className="flex flex-col gap-3">
180+
<div className="border-b border-grid-dimmed pb-1">
181+
<Header2>Your concurrency</Header2>
182+
</div>
183+
{isManagedCloud ? (
184+
<>
185+
<Paragraph variant="small">
186+
Concurrency limits determine how many runs you can execute at the same time. You can
187+
upgrade your plan to get more concurrency. You are currently on the{" "}
188+
{plan?.v3Subscription?.plan?.title ?? "Free"} plan.
189+
</Paragraph>
190+
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
191+
Upgrade for more concurrency
192+
</LinkButton>
193+
</>
194+
) : null}
195+
<div className="mt-3 flex flex-col gap-3">
196+
<Table>
197+
<TableHeader>
198+
<TableRow>
199+
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
200+
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
201+
</TableRow>
202+
</TableHeader>
203+
<TableBody>
204+
{environments.map((environment) => (
205+
<TableRow key={environment.id}>
206+
<TableCell className="pl-0">
207+
<EnvironmentCombo environment={environment} />
208+
</TableCell>
209+
<TableCell alignment="right">{environment.maximumConcurrencyLimit}</TableCell>
210+
</TableRow>
211+
))}
212+
</TableBody>
213+
</Table>
214+
</div>
215+
</div>
216+
);
217+
}

0 commit comments

Comments
 (0)