+
{
+ // Allow empty string to pass through so user can clear the field
+ if (e.currentTarget.value === "") {
+ // reflect emptiness in the input and notify consumer as empty
+ if (inputRef.current) inputRef.current.value = "";
+ onChange?.({
+ target: e.currentTarget,
+ currentTarget: e.currentTarget,
+ } as ChangeEvent
);
+ return;
+ }
+ onChange?.(e);
+ }}
+ onBlur={(e) => {
+ // If blur is caused by clicking our step buttons, we prevent pointerdown
+ // so blur shouldn't fire. This is for safety in case of keyboard focus move.
+ if (round) commitRoundedFromInput();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && round) {
+ e.preventDefault();
+ commitRoundedFromInput();
+ }
+ }}
+ step={step}
+ min={min}
+ max={max}
+ disabled={disabled}
+ readOnly={readOnly}
+ className={cn(
+ "placeholder:text-muted-foreground h-full grow border-0 bg-transparent text-left text-text-bright outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed",
+ size.input,
+ // Hide number input arrows
+ "[type=number]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ )}
+ {...props}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/primitives/Toast.tsx b/apps/webapp/app/components/primitives/Toast.tsx
index 21256e89ce..5d8b002c49 100644
--- a/apps/webapp/app/components/primitives/Toast.tsx
+++ b/apps/webapp/app/components/primitives/Toast.tsx
@@ -1,12 +1,15 @@
-import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
-import { Toaster, toast } from "sonner";
-
-import { useTypedLoaderData } from "remix-typedjson";
-import { loader } from "~/root";
+import { useSearchParams } from "@remix-run/react";
import { useEffect } from "react";
-import { Paragraph } from "./Paragraph";
+import { useTypedLoaderData } from "remix-typedjson";
+import { Toaster, toast } from "sonner";
+import { type ToastMessageAction } from "~/models/message.server";
+import { type loader } from "~/root";
import { cn } from "~/utils/cn";
+import { Button, LinkButton } from "./Buttons";
+import { Header2 } from "./Headers";
+import { Paragraph } from "./Paragraph";
const defaultToastDuration = 5000;
const permanentToastDuration = 60 * 60 * 24 * 1000;
@@ -19,9 +22,22 @@ export function Toast() {
}
const { message, type, options } = toastMessage;
- toast.custom((t) =>
{variant === "success" ? (
-
+
) : (
-
+
)}
-
{message}
+
+ {title &&
{title}}
+
+ {message}
+
+
+
);
}
+
+function Action({
+ action,
+ toastId,
+ className,
+}: {
+ action?: ToastMessageAction;
+ toastId: string;
+ className?: string;
+}) {
+ const [_, setSearchParams] = useSearchParams();
+
+ if (!action) return null;
+
+ switch (action.action.type) {
+ case "link": {
+ return (
+
+ {action.label}
+
+ );
+ }
+ case "help": {
+ const feedbackType = action.action.feedbackType;
+ return (
+
+ );
+ }
+ }
+}
diff --git a/apps/webapp/app/models/message.server.ts b/apps/webapp/app/models/message.server.ts
index cb6ca2963a..f19995f61c 100644
--- a/apps/webapp/app/models/message.server.ts
+++ b/apps/webapp/app/models/message.server.ts
@@ -1,7 +1,8 @@
-import { json, Session } from "@remix-run/node";
-import { createCookieSessionStorage } from "@remix-run/node";
+import { json, createCookieSessionStorage, type Session } from "@remix-run/node";
import { redirect, typedjson } from "remix-typedjson";
+import { ButtonVariant } from "~/components/primitives/Buttons";
import { env } from "~/env.server";
+import { type FeedbackType } from "~/routes/resources.feedback";
export type ToastMessage = {
message: string;
@@ -9,9 +10,26 @@ export type ToastMessage = {
options: Required
;
};
+export type ToastMessageAction = {
+ label: string;
+ variant?: ButtonVariant;
+ action:
+ | {
+ type: "link";
+ path: string;
+ }
+ | {
+ type: "help";
+ feedbackType: FeedbackType;
+ };
+};
+
export type ToastMessageOptions = {
+ title?: string;
/** Ephemeral means it disappears after a delay, defaults to true */
ephemeral?: boolean;
+ /** This display a button and make it not ephemeral, unless ephemeral is explicitlyset to false */
+ action?: ToastMessageAction;
};
const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
@@ -36,6 +54,7 @@ export function setSuccessMessage(
message,
type: "success",
options: {
+ ...options,
ephemeral: options?.ephemeral ?? true,
},
} as ToastMessage);
@@ -46,6 +65,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
message,
type: "error",
options: {
+ ...options,
ephemeral: options?.ephemeral ?? true,
},
} as ToastMessage);
diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts
index eb61749413..66b1d5c5b2 100644
--- a/apps/webapp/app/models/organization.server.ts
+++ b/apps/webapp/app/models/organization.server.ts
@@ -12,7 +12,7 @@ import { prisma, type PrismaClientOrTransaction } from "~/db.server";
import { env } from "~/env.server";
import { featuresForUrl } from "~/features.server";
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
-
+import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
export type { Organization };
const nanoid = customAlphabet("1234567890abcdef", 4);
@@ -96,6 +96,8 @@ export async function createEnvironment({
const pkApiKey = createPkApiKeyForEnv(type);
const shortcode = createShortcode().join("-");
+ const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type);
+
return await prismaClient.runtimeEnvironment.create({
data: {
slug,
@@ -103,7 +105,7 @@ export async function createEnvironment({
pkApiKey,
shortcode,
autoEnableInternalSources: type !== "DEVELOPMENT",
- maximumConcurrencyLimit: organization.maximumConcurrencyLimit / 3,
+ maximumConcurrencyLimit: limit,
organization: {
connect: {
id: organization.id,
diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts
index 10a2f0c02a..736df96ba1 100644
--- a/apps/webapp/app/models/project.server.ts
+++ b/apps/webapp/app/models/project.server.ts
@@ -16,12 +16,26 @@ type Options = {
version: "v2" | "v3";
};
+export class ExceededProjectLimitError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ExceededProjectLimitError";
+ }
+}
+
export async function createProject(
{ organizationSlug, name, userId, version }: Options,
attemptCount = 0
): Promise {
//check the user has permissions to do this
const organization = await prisma.organization.findFirst({
+ select: {
+ id: true,
+ slug: true,
+ v3Enabled: true,
+ maximumConcurrencyLimit: true,
+ maximumProjectCount: true,
+ },
where: {
slug: organizationSlug,
members: { some: { userId } },
@@ -40,6 +54,19 @@ export async function createProject(
}
}
+ const projectCount = await prisma.project.count({
+ where: {
+ organizationId: organization.id,
+ deletedAt: null,
+ },
+ });
+
+ if (projectCount >= organization.maximumProjectCount) {
+ throw new ExceededProjectLimitError(
+ `This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
+ );
+ }
+
//ensure the slug is globally unique
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
const projectWithSameSlug = await prisma.project.findFirst({
diff --git a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts
new file mode 100644
index 0000000000..d6464fef62
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts
@@ -0,0 +1,132 @@
+import { type RuntimeEnvironmentType } from "@trigger.dev/database";
+import {
+ getCurrentPlan,
+ getDefaultEnvironmentLimitFromPlan,
+ getPlans,
+} from "~/services/platform.v3.server";
+import { BasePresenter } from "./basePresenter.server";
+import { sortEnvironments } from "~/utils/environmentSort";
+
+export type ConcurrencyResult = {
+ canAddConcurrency: boolean;
+ environments: EnvironmentWithConcurrency[];
+ extraConcurrency: number;
+ extraAllocatedConcurrency: number;
+ extraUnallocatedConcurrency: number;
+ maxQuota: number;
+ concurrencyPricing: {
+ stepSize: number;
+ centsPerStep: number;
+ };
+};
+
+export type EnvironmentWithConcurrency = {
+ id: string;
+ type: RuntimeEnvironmentType;
+ isBranchableEnvironment: boolean;
+ branchName: string | null;
+ parentEnvironmentId: string | null;
+ maximumConcurrencyLimit: number;
+ planConcurrencyLimit: number;
+};
+
+export class ManageConcurrencyPresenter extends BasePresenter {
+ public async call({
+ userId,
+ projectId,
+ organizationId,
+ }: {
+ userId: string;
+ projectId: string;
+ organizationId: string;
+ }): Promise {
+ // Get plan
+ const currentPlan = await getCurrentPlan(organizationId);
+ if (!currentPlan) {
+ throw new Error("No plan found");
+ }
+
+ const canAddConcurrency =
+ currentPlan.v3Subscription.plan?.limits.concurrentRuns.canExceed === true;
+
+ const environments = await this._replica.runtimeEnvironment.findMany({
+ select: {
+ id: true,
+ projectId: true,
+ type: true,
+ branchName: true,
+ parentEnvironmentId: true,
+ isBranchableEnvironment: true,
+ maximumConcurrencyLimit: true,
+ orgMember: {
+ select: {
+ userId: true,
+ },
+ },
+ },
+ where: {
+ organizationId,
+ },
+ });
+
+ const extraConcurrency = currentPlan?.v3Subscription.addOns?.concurrentRuns?.purchased ?? 0;
+
+ // Go through all environments and add up extra concurrency above their allowed allocation
+ let extraAllocatedConcurrency = 0;
+ const projectEnvironments: EnvironmentWithConcurrency[] = [];
+ for (const environment of environments) {
+ // Don't count parent environments
+ if (environment.isBranchableEnvironment) continue;
+
+ const limit = currentPlan
+ ? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan)
+ : 0;
+ if (!limit) continue;
+
+ // If it's not DEV and they've increased, track that
+ // You can't spend money to increase DEV concurrency
+ if (environment.type !== "DEVELOPMENT" && environment.maximumConcurrencyLimit > limit) {
+ extraAllocatedConcurrency += environment.maximumConcurrencyLimit - limit;
+ }
+
+ // We only want to show this project's environments
+ if (environment.projectId === projectId) {
+ if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) {
+ continue;
+ }
+
+ projectEnvironments.push({
+ id: environment.id,
+ type: environment.type,
+ isBranchableEnvironment: environment.isBranchableEnvironment,
+ branchName: environment.branchName,
+ parentEnvironmentId: environment.parentEnvironmentId,
+ maximumConcurrencyLimit: environment.maximumConcurrencyLimit,
+ planConcurrencyLimit: limit,
+ });
+ }
+ }
+
+ const extraAllocated = Math.min(extraConcurrency, extraAllocatedConcurrency);
+
+ const plans = await getPlans();
+ if (!plans) {
+ throw new Error("Couldn't retrieve add on pricing");
+ }
+
+ return {
+ canAddConcurrency,
+ extraConcurrency,
+ extraAllocatedConcurrency: extraAllocated,
+ extraUnallocatedConcurrency: extraConcurrency - extraAllocated,
+ maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0,
+ environments: sortEnvironments(projectEnvironments, [
+ "PRODUCTION",
+ "STAGING",
+ "PREVIEW",
+ "DEVELOPMENT",
+ ]),
+ concurrencyPricing: plans.addOnPricing.concurrency,
+ };
+ }
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx
new file mode 100644
index 0000000000..5406815314
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx
@@ -0,0 +1,808 @@
+import { conform, useFieldList, useForm } from "@conform-to/react";
+import { parse } from "@conform-to/zod";
+import {
+ EnvelopeIcon,
+ ExclamationTriangleIcon,
+ InformationCircleIcon,
+ PlusIcon,
+} from "@heroicons/react/20/solid";
+import { DialogClose } from "@radix-ui/react-dialog";
+import {
+ Form,
+ useActionData,
+ useNavigate,
+ useNavigation,
+ useSearchParams,
+ type MetaFunction,
+} from "@remix-run/react";
+import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { tryCatch } from "@trigger.dev/core";
+import { useEffect, useState } from "react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { z } from "zod";
+import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
+import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
+import {
+ MainHorizontallyCenteredContainer,
+ PageBody,
+ PageContainer,
+} from "~/components/layout/AppLayout";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
+import { Fieldset } from "~/components/primitives/Fieldset";
+import { FormButtons } from "~/components/primitives/FormButtons";
+import { FormError } from "~/components/primitives/FormError";
+import { Header2, Header3 } from "~/components/primitives/Headers";
+import { Input } from "~/components/primitives/Input";
+import { InputGroup } from "~/components/primitives/InputGroup";
+import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
+import { Label } from "~/components/primitives/Label";
+import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import * as Property from "~/components/primitives/PropertyTable";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+} from "~/components/primitives/Table";
+import { InfoIconTooltip } from "~/components/primitives/Tooltip";
+import { useFeatures } from "~/hooks/useFeatures";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
+import { findProjectBySlug } from "~/models/project.server";
+import {
+ ManageConcurrencyPresenter,
+ type ConcurrencyResult,
+ type EnvironmentWithConcurrency,
+} from "~/presenters/v3/ManageConcurrencyPresenter.server";
+import { getPlans } from "~/services/platform.v3.server";
+import { requireUserId } from "~/services/session.server";
+import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
+import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
+import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
+import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
+import { SpinnerWhite } from "~/components/primitives/Spinner";
+import { cn } from "~/utils/cn";
+import { logger } from "~/services/logger.server";
+import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
+
+export const meta: MetaFunction = () => {
+ return [
+ {
+ title: `Manage concurrency | Trigger.dev`,
+ },
+ ];
+};
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ throw new Response(undefined, {
+ status: 404,
+ statusText: "Project not found",
+ });
+ }
+
+ const presenter = new ManageConcurrencyPresenter();
+ const [error, result] = await tryCatch(
+ presenter.call({
+ userId: userId,
+ projectId: project.id,
+ organizationId: project.organizationId,
+ })
+ );
+
+ if (error) {
+ throw new Response(undefined, {
+ status: 400,
+ statusText: error.message,
+ });
+ }
+
+ const plans = await tryCatch(getPlans());
+ if (!plans) {
+ throw new Response(null, { status: 404, statusText: "Plans not found" });
+ }
+
+ return typedjson(result);
+};
+
+const FormSchema = z.discriminatedUnion("action", [
+ z.object({
+ action: z.enum(["purchase"]),
+ amount: z.coerce.number().min(0, "Amount must be 0 or more"),
+ }),
+ z.object({
+ action: z.enum(["quota-increase"]),
+ amount: z.coerce.number().min(1, "Amount must be greater than 0"),
+ }),
+ z.object({
+ action: z.enum(["allocate"]),
+ // It will only update environments that are passed in
+ environments: z.array(
+ z.object({
+ id: z.string(),
+ amount: z.coerce.number().min(0, "Amount must be 0 or more"),
+ })
+ ),
+ }),
+]);
+
+export const action = async ({ request, params }: ActionFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ const redirectPath = concurrencyPath(
+ { slug: organizationSlug },
+ { slug: projectParam },
+ { slug: envParam }
+ );
+
+ if (!project) {
+ throw redirectWithErrorMessage(redirectPath, request, "Project not found");
+ }
+
+ const formData = await request.formData();
+ const submission = parse(formData, { schema: FormSchema });
+
+ if (!submission.value || submission.intent !== "submit") {
+ return json(submission);
+ }
+
+ if (submission.value.action === "allocate") {
+ const allocate = new AllocateConcurrencyService();
+ const [error, result] = await tryCatch(
+ allocate.call({
+ userId,
+ projectId: project.id,
+ organizationId: project.organizationId,
+ environments: submission.value.environments,
+ })
+ );
+
+ if (error) {
+ submission.error.environments = [error instanceof Error ? error.message : "Unknown error"];
+ return json(submission);
+ }
+
+ if (!result.success) {
+ submission.error.environments = [result.error];
+ return json(submission);
+ }
+
+ return redirectWithSuccessMessage(
+ `${redirectPath}?success=true`,
+ request,
+ "Concurrency allocated successfully"
+ );
+ }
+
+ const service = new SetConcurrencyAddOnService();
+ const [error, result] = await tryCatch(
+ service.call({
+ userId,
+ projectId: project.id,
+ organizationId: project.organizationId,
+ action: submission.value.action,
+ amount: submission.value.amount,
+ })
+ );
+
+ if (error) {
+ submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
+ return json(submission);
+ }
+
+ if (!result.success) {
+ submission.error.amount = [result.error];
+ return json(submission);
+ }
+
+ return redirectWithSuccessMessage(
+ `${redirectPath}?success=true`,
+ request,
+ submission.value.action === "purchase"
+ ? "Concurrency updated successfully"
+ : "Requested extra concurrency, we'll get back to you soon."
+ );
+};
+
+export default function Page() {
+ const {
+ canAddConcurrency,
+ extraConcurrency,
+ extraAllocatedConcurrency,
+ extraUnallocatedConcurrency,
+ environments,
+ concurrencyPricing,
+ maxQuota,
+ } = useTypedLoaderData();
+
+ return (
+
+
+
+
+
+
+ {environments.map((environment) => (
+
+
+ {environment.type}{" "}
+ {environment.branchName ? ` (${environment.branchName})` : ""}
+
+ {environment.id}
+
+ ))}
+
+
+
+
+
+
+ {canAddConcurrency ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function initialAllocation(environments: ConcurrencyResult["environments"]) {
+ return new Map(
+ environments
+ .filter((e) => e.type !== "DEVELOPMENT")
+ .map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)])
+ );
+}
+
+function allocationTotal(environments: ConcurrencyResult["environments"]) {
+ const allocation = initialAllocation(environments);
+ return Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
+}
+
+function Upgradable({
+ extraConcurrency,
+ extraAllocatedConcurrency,
+ extraUnallocatedConcurrency,
+ environments,
+ concurrencyPricing,
+ maxQuota,
+}: ConcurrencyResult) {
+ const lastSubmission = useActionData();
+ const [form, { environments: formEnvironments }] = useForm({
+ id: "purchase-concurrency",
+ // TODO: type this
+ lastSubmission: lastSubmission as any,
+ onValidate({ formData }) {
+ return parse(formData, { schema: FormSchema });
+ },
+ shouldRevalidate: "onSubmit",
+ });
+
+ const navigation = useNavigation();
+ const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
+
+ const [allocation, setAllocation] = useState(initialAllocation(environments));
+
+ const allocatedInProject = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
+ const initialAllocationInProject = allocationTotal(environments);
+ const changeInAllocation = allocatedInProject - initialAllocationInProject;
+ const unallocated = extraUnallocatedConcurrency - changeInAllocation;
+ const allocationModified = changeInAllocation !== 0;
+
+ return (
+
+
+ Manage your concurrency
+
+
+ Concurrency limits determine how many runs you can execute at the same time. You can add
+ extra concurrency to your organization which you can allocate to environments in your
+ projects.
+
+
+
+
+
+
+
+ Extra concurrency purchased
+
+ {extraConcurrency}
+
+
+
+ Allocated concurrency
+
+ {allocationModified ? (
+ <>
+
+ {extraAllocatedConcurrency}
+ {" "}
+ {extraAllocatedConcurrency + changeInAllocation}
+ >
+ ) : (
+ extraAllocatedConcurrency
+ )}
+
+
+
+ Unallocated concurrency
+ 0
+ ? "text-success"
+ : unallocated < 0
+ ? "text-error"
+ : "text-text-bright"
+ )}
+ >
+ {allocationModified ? (
+ <>
+
+ {extraUnallocatedConcurrency}
+ {" "}
+ {extraUnallocatedConcurrency - changeInAllocation}
+ >
+ ) : (
+ extraUnallocatedConcurrency
+ )}
+
+
+
+
+
+ {allocationModified ? (
+ unallocated < 0 ? (
+
+
+
+ You're trying to allocate more concurrency than your total purchased
+ amount.
+
+
+ ) : (
+
+
+
+
+ Save your changes or{" "}
+
+ .
+
+
+
+
+ )
+ ) : (
+ <>>
+ )}
+
+
+
+
+
+
{formEnvironments.error}
+
+
+
+
+ );
+}
+
+function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) {
+ const { isManagedCloud } = useFeatures();
+ const plan = useCurrentPlan();
+ const organization = useOrganization();
+
+ return (
+
+
+ Your concurrency
+
+ {isManagedCloud ? (
+ <>
+
+ Concurrency limits determine how many runs you can execute at the same time. You can
+ upgrade your plan to get more concurrency. You are currently on the{" "}
+ {plan?.v3Subscription?.plan?.title ?? "Free"} plan.
+
+
+ Upgrade for more concurrency
+
+ >
+ ) : null}
+
+
+
+
+ Environment
+ Concurrency limit
+
+
+
+ {environments.map((environment) => (
+
+
+
+
+ {environment.maximumConcurrencyLimit}
+
+ ))}
+
+
+
+
+ );
+}
+
+function PurchaseConcurrencyModal({
+ concurrencyPricing,
+ extraConcurrency,
+ extraUnallocatedConcurrency,
+ maxQuota,
+ disabled,
+}: {
+ concurrencyPricing: {
+ stepSize: number;
+ centsPerStep: number;
+ };
+ extraConcurrency: number;
+ extraUnallocatedConcurrency: number;
+ maxQuota: number;
+ disabled: boolean;
+}) {
+ const lastSubmission = useActionData();
+ const [form, { amount }] = useForm({
+ id: "purchase-concurrency",
+ // TODO: type this
+ lastSubmission: lastSubmission as any,
+ onValidate({ formData }) {
+ return parse(formData, { schema: FormSchema });
+ },
+ shouldRevalidate: "onSubmit",
+ });
+
+ const [amountValue, setAmountValue] = useState(extraConcurrency);
+ const navigation = useNavigation();
+ const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
+
+ // Close the panel, when we've succeeded
+ // This is required because a redirect to the same path doesn't clear state
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [open, setOpen] = useState(false);
+ useEffect(() => {
+ const success = searchParams.get("success");
+ if (success) {
+ setOpen(false);
+ setSearchParams((s) => {
+ s.delete("success");
+ return s;
+ });
+ }
+ }, [searchParams.get("success")]);
+
+ const state = updateState({
+ value: amountValue,
+ existingValue: extraConcurrency,
+ quota: maxQuota,
+ extraUnallocatedConcurrency,
+ });
+ const changeClassName =
+ state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
+
+ const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency";
+
+ return (
+
+ );
+}
+
+function updateState({
+ value,
+ existingValue,
+ quota,
+ extraUnallocatedConcurrency,
+}: {
+ value: number;
+ existingValue: number;
+ quota: number;
+ extraUnallocatedConcurrency: number;
+}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_increase_unallocated" {
+ if (value === existingValue) return "no_change";
+ if (value < existingValue) {
+ const difference = existingValue - value;
+ if (difference > extraUnallocatedConcurrency) {
+ return "need_to_increase_unallocated";
+ }
+ return "decrease";
+ }
+ if (value > quota) return "above_quota";
+ return "increase";
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx
index 12d8cf208d..0c069f31e6 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx
@@ -68,11 +68,18 @@ import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePrese
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
-import { docsPath, EnvironmentParamSchema, v3BillingPath, v3RunsPath } from "~/utils/pathBuilder";
+import {
+ concurrencyPath,
+ docsPath,
+ EnvironmentParamSchema,
+ v3BillingPath,
+ v3RunsPath,
+} from "~/utils/pathBuilder";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
+import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
const SearchParamsSchema = z.object({
query: z.string().optional(),
@@ -406,18 +413,14 @@ export default function Page() {
accessory={
plan ? (
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
-
- Increase limit…
-
- }
- defaultValue="concurrency"
- />
+
+ Increase limit
+
) : (
{
request,
`${submission.value.projectName} created`
);
- } catch (error: any) {
- return json({ errors: { body: error.message } }, { status: 400 });
+ } catch (error) {
+ if (error instanceof ExceededProjectLimitError) {
+ return redirectWithErrorMessage(
+ newProjectPath({ slug: organizationSlug }),
+ request,
+ error.message,
+ {
+ title: "Failed to create project",
+ action: {
+ label: "Request more projects",
+ variant: "secondary/small",
+ action: { type: "help", feedbackType: "help" },
+ },
+ }
+ );
+ }
+
+ return redirectWithErrorMessage(
+ newProjectPath({ slug: organizationSlug }),
+ request,
+ error instanceof Error ? error.message : "Something went wrong",
+ { ephemeral: false }
+ );
}
};
@@ -191,6 +214,7 @@ export default function Page() {
+