diff --git a/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx b/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx new file mode 100644 index 0000000000..710ba4e6fa --- /dev/null +++ b/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx @@ -0,0 +1,13 @@ +export function ConcurrencyIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/Feedback.tsx b/apps/webapp/app/components/Feedback.tsx index cba709aba4..ecfd4e88c9 100644 --- a/apps/webapp/app/components/Feedback.tsx +++ b/apps/webapp/app/components/Feedback.tsx @@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid"; import { EnvelopeIcon } from "@heroicons/react/24/solid"; -import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react"; +import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react"; import { type ReactNode, useEffect, useState } from "react"; import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback"; import { Button } from "./primitives/Buttons"; @@ -23,10 +23,12 @@ import { DialogClose } from "@radix-ui/react-dialog"; type FeedbackProps = { button: ReactNode; defaultValue?: FeedbackType; + onOpenChange?: (open: boolean) => void; }; -export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) { +export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) { const [open, setOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const lastSubmission = useActionData(); const navigation = useNavigation(); @@ -52,8 +54,26 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) { } }, [navigation, form]); + // Handle URL param functionality + useEffect(() => { + const open = searchParams.get("feedbackPanel"); + if (open) { + setType(open as FeedbackType); + setOpen(true); + // Clone instead of mutating in place + const next = new URLSearchParams(searchParams); + next.delete("feedbackPanel"); + setSearchParams(next); + } + }, [searchParams]); + + const handleOpenChange = (value: boolean) => { + setOpen(value); + onOpenChange?.(value); + }; + return ( - + {button} Contact us diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index ba31b0ceaa..bda24a32ea 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -24,6 +24,7 @@ import { Link, useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import simplur from "simplur"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; @@ -43,6 +44,7 @@ import { accountPath, adminPath, branchesPath, + concurrencyPath, logoutPath, newOrganizationPath, newProjectPath, @@ -122,6 +124,7 @@ export function SideMenu({ const { isConnected } = useDevPresence(); const isFreeUser = currentPlan?.v3Subscription?.isPaying === false; const isAdmin = useHasAdminAccess(); + const { isManagedCloud } = useFeatures(); useEffect(() => { const handleScroll = () => { @@ -313,6 +316,15 @@ export function SideMenu({ data-action="preview-branches" badge={} /> + {isManagedCloud && ( + + )} & { diff --git a/apps/webapp/app/components/primitives/InputNumberStepper.tsx b/apps/webapp/app/components/primitives/InputNumberStepper.tsx new file mode 100644 index 0000000000..f4aafd5cae --- /dev/null +++ b/apps/webapp/app/components/primitives/InputNumberStepper.tsx @@ -0,0 +1,220 @@ +import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { type ChangeEvent, useRef } from "react"; +import { cn } from "~/utils/cn"; + +type InputNumberStepperProps = Omit & { + step?: number; + min?: number; + max?: number; + round?: boolean; + controlSize?: "base" | "large"; +}; + +export function InputNumberStepper({ + value, + onChange, + step = 50, + min, + max, + round = true, + controlSize = "base", + name, + id, + disabled = false, + readOnly = false, + className, + placeholder = "Type a number", + ...props +}: InputNumberStepperProps) { + const inputRef = useRef(null); + + const handleStepUp = () => { + if (!inputRef.current || disabled) return; + + // If rounding is enabled, ensure we start from a rounded base before stepping + if (round) { + // If field is empty, treat as 0 (or min if provided) before stepping up + if (inputRef.current.value === "") { + inputRef.current.value = String(min ?? 0); + } else { + commitRoundedFromInput(); + } + } + inputRef.current.stepUp(); + const event = new Event("change", { bubbles: true }); + inputRef.current.dispatchEvent(event); + }; + + const handleStepDown = () => { + if (!inputRef.current || disabled) return; + + // If rounding is enabled, ensure we start from a rounded base before stepping + if (round) { + // If field is empty, treat as 0 (or min if provided) before stepping down + if (inputRef.current.value === "") { + inputRef.current.value = String(min ?? 0); + } else { + commitRoundedFromInput(); + } + } + inputRef.current.stepDown(); + const event = new Event("change", { bubbles: true }); + inputRef.current.dispatchEvent(event); + }; + + const numericValue = value === "" ? NaN : (value as number); + const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min; + const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max; + + function clamp(val: number): number { + if (Number.isNaN(val)) return typeof value === "number" ? value : min ?? 0; + let next = val; + if (min !== undefined) next = Math.max(min, next); + if (max !== undefined) next = Math.min(max, next); + return next; + } + + function roundToStep(val: number): number { + if (step <= 0) return val; + const base = min ?? 0; + const shifted = val - base; + const quotient = shifted / step; + const floored = Math.floor(quotient); + const ceiled = Math.ceil(quotient); + const down = base + floored * step; + const up = base + ceiled * step; + const distDown = Math.abs(val - down); + const distUp = Math.abs(up - val); + return distUp < distDown ? up : down; + } + + function commitRoundedFromInput() { + if (!inputRef.current || disabled || readOnly) return; + const el = inputRef.current; + const raw = el.value; + if (raw === "") return; // do not coerce empty to 0; keep placeholder visible + const numeric = Number(raw); + if (Number.isNaN(numeric)) return; // ignore non-numeric + const rounded = clamp(roundToStep(numeric)); + if (String(rounded) === String(value)) return; + // Update the real input's value for immediate UI feedback + el.value = String(rounded); + // Invoke consumer onChange with the real element as target/currentTarget + onChange?.({ + target: el, + currentTarget: el, + } as unknown as ChangeEvent); + } + + const sizeStyles = { + base: { + container: "h-9", + input: "text-sm px-3", + button: "size-6", + icon: "size-3.5", + gap: "gap-1 pr-1.5", + }, + large: { + container: "h-11 rounded-md", + input: "text-base px-3.5", + button: "size-8", + icon: "size-5", + gap: "gap-[0.3125rem] pr-[0.3125rem]", + }, + } as const; + + const size = sizeStyles[controlSize]; + + return ( +
+ { + // 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) => , { - duration: options.ephemeral ? defaultToastDuration : permanentToastDuration, - }); + const ephemeral = options.action ? false : options.ephemeral; + + toast.custom( + (t) => ( + + ), + { + duration: ephemeral ? defaultToastDuration : permanentToastDuration, + } + ); }, [toastMessage]); return ; @@ -32,11 +48,15 @@ export function ToastUI({ message, t, toastWidth = 356, // Default width, matches what sonner provides by default + title, + action, }: { variant: "error" | "success"; message: string; t: string; toastWidth?: string | number; + title?: string; + action?: ToastMessageAction; }) { return (
{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 + +
+ + + + 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} +
+
+ +
+ Concurrency allocation +
+ + + + Environment + + + Included{" "} + + + + Extra concurrency + Total + + + + {environments.map((environment, index) => ( + + + + + {environment.planConcurrencyLimit} + +
+ {environment.type === "DEVELOPMENT" ? ( + Math.max( + 0, + environment.maximumConcurrencyLimit - environment.planConcurrencyLimit + ) + ) : ( + <> + + { + const value = e.target.value === "" ? 0 : Number(e.target.value); + setAllocation(new Map(allocation).set(environment.id, value)); + }} + min={0} + /> + + )} +
+
+ + {environment.planConcurrencyLimit + (allocation.get(environment.id) ?? 0)} + +
+ ))} +
+
+
+
+
+ ); +} + +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 ( + + + + + + {title} +
+
+ + You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "} + {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. Or you can + remove any extra concurrency after you have unallocated it from your environments + first. + +
+ + + setAmountValue(Number(e.target.value))} + disabled={isLoading} + /> + {amount.error} + {form.error} + +
+ {state === "need_to_increase_unallocated" ? ( +
+ + You need to unallocate{" "} + {formatNumber(extraConcurrency - amountValue - extraUnallocatedConcurrency)} more + concurrency from your environments in order to remove{" "} + {formatNumber(extraConcurrency - amountValue)} concurrency from your account. + +
+ ) : state === "above_quota" ? ( +
+ + Currently you can only have up to {maxQuota} extra concurrency. Send a request + below to lift your current limit. We'll get back to you soon. + +
+ ) : ( +
+
+ Summary + Total +
+
+ + {formatNumber(extraConcurrency)}{" "} + current total + + + {formatCurrency( + (extraConcurrency * concurrencyPricing.centsPerStep) / + concurrencyPricing.stepSize / + 100, + true + )} + +
+
+ + ({extraConcurrency / concurrencyPricing.stepSize} bundles) + + /mth +
+
+ + {state === "increase" ? "+" : null} + {formatNumber(amountValue - extraConcurrency)} + + + {state === "increase" ? "+" : null} + {formatCurrency( + ((amountValue - extraConcurrency) * concurrencyPricing.centsPerStep) / + concurrencyPricing.stepSize / + 100, + true + )} + +
+
+ + ({(amountValue - extraConcurrency) / concurrencyPricing.stepSize} bundles @{" "} + {formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth) + + /mth +
+
+ + {formatNumber(amountValue)} new total + + + {formatCurrency( + (amountValue * concurrencyPricing.centsPerStep) / + concurrencyPricing.stepSize / + 100, + true + )} + +
+
+ + ({amountValue / concurrencyPricing.stepSize} bundles) + + /mth +
+
+ )} +
+ + + + + ) : state === "decrease" || state === "need_to_increase_unallocated" ? ( + <> + + + + ) : ( + <> + + + + ) + } + cancelButton={ + + + + } + /> + +
+
+ ); +} + +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() {
+ } /> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx index 37401263c5..f5402559bd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx @@ -45,7 +45,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } export default function ChoosePlanPage() { - const { plans, v3Subscription, organizationSlug, periodEnd } = + const { plans, v3Subscription, organizationSlug, periodEnd, addOnPricing } = useTypedLoaderData(); return ( @@ -57,6 +57,7 @@ export default function ChoosePlanPage() {
{ - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - return redirect( - v3QueuesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }) - ); -}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 8d9e19bfc6..4e1c5ce40d 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -11,6 +11,7 @@ import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { uiComponent } from "@team-plain/typescript-sdk"; import { GitHubLightIcon } from "@trigger.dev/companyicons"; import { + AddOnPricing, type FreePlanDefinition, type Limits, type PaidPlanDefinition, @@ -45,6 +46,8 @@ import { requireUser } from "~/services/session.server"; import { engine } from "~/v3/runEngine.server"; import { cn } from "~/utils/cn"; import { sendToPlain } from "~/utils/plain.server"; +import { formatCurrency } from "~/utils/numberFormatter"; +import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; const Params = z.object({ organizationSlug: z.string(), @@ -153,7 +156,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } } - return setPlan(organization, request, form.callerPath, payload, { + return await setPlan(organization, request, form.callerPath, payload, { invalidateBillingCache: engine.invalidateBillingCache.bind(engine), }); } @@ -173,7 +176,6 @@ const pricingDefinitions = { }, additionalConcurrency: { title: "Additional concurrency", - content: "Then $50/month per 50", }, taskRun: { title: "Task runs", @@ -227,6 +229,7 @@ const pricingDefinitions = { type PricingPlansProps = { plans: Plans; + concurrencyAddOnPricing: AddOnPricing; subscription?: SubscriptionResult; organizationSlug: string; hasPromotedPlan: boolean; @@ -236,6 +239,7 @@ type PricingPlansProps = { export function PricingPlans({ plans, + concurrencyAddOnPricing, subscription, organizationSlug, hasPromotedPlan, @@ -258,7 +262,12 @@ export function PricingPlans({ subscription={subscription} isHighlighted={hasPromotedPlan} /> - +
@@ -654,10 +663,12 @@ export function TierHobby({ export function TierPro({ plan, + concurrencyAddOnPricing, organizationSlug, subscription, }: { plan: PaidPlanDefinition; + concurrencyAddOnPricing: AddOnPricing; organizationSlug: string; subscription?: SubscriptionResult; }) { @@ -747,7 +758,9 @@ export function TierPro({
    - {pricingDefinitions.additionalConcurrency.content} + {`Then ${formatCurrency(concurrencyAddOnPricing.centsPerStep / 100, true)}/month per ${ + concurrencyAddOnPricing.stepSize + }`} Unlimited{" "} @@ -963,10 +976,45 @@ function ConcurrentRuns({ limits, children }: { limits: Limits; children?: React ) : ( <>{limits.concurrentRuns.number} - )}{" "} + )} + + {pricingDefinitions.concurrentRuns.content} + + +
    + + + {limits.concurrentRuns.production} + {limits.concurrentRuns.canExceed ? "+" : ""} + +
    +
    + + + {limits.concurrentRuns.staging} + {limits.concurrentRuns.canExceed ? "+" : ""} + +
    +
    + + + {limits.concurrentRuns.preview} + {limits.concurrentRuns.canExceed ? "+" : ""} + +
    +
    + + + {limits.concurrentRuns.development} + {limits.concurrentRuns.canExceed ? "+" : ""} + +
    +
+ } > concurrent runs diff --git a/apps/webapp/app/routes/storybook.input-fields/route.tsx b/apps/webapp/app/routes/storybook.input-fields/route.tsx index 62794fab7b..e6402d732c 100644 --- a/apps/webapp/app/routes/storybook.input-fields/route.tsx +++ b/apps/webapp/app/routes/storybook.input-fields/route.tsx @@ -20,6 +20,9 @@ function InputFieldSet({ disabled }: { disabled?: boolean }) { + + +
} />
-
- } - accessory={} - /> - } - accessory={} - /> - } - accessory={} - /> - } - accessory={} - /> - } - accessory={} - /> - } - accessory={} - /> -
); } diff --git a/apps/webapp/app/routes/storybook.stepper/route.tsx b/apps/webapp/app/routes/storybook.stepper/route.tsx new file mode 100644 index 0000000000..3cbe8f2f63 --- /dev/null +++ b/apps/webapp/app/routes/storybook.stepper/route.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { InputNumberStepper } from "~/components/primitives/InputNumberStepper"; + +export default function Story() { + const [value1, setValue1] = useState(0); + const [value2, setValue2] = useState(100); + const [value3, setValue3] = useState(0); + const [value4, setValue4] = useState(250); + const [value5, setValue5] = useState(250); + + return ( +
+
+
+ InputNumberStepper + Size: base (default) +
+ + setValue1(e.target.value === "" ? "" : Number(e.target.value))} + step={75} + /> +
+ +
+ + setValue2(e.target.value === "" ? "" : Number(e.target.value))} + step={50} + min={0} + max={1000} + /> +
+ +
+ + setValue3(e.target.value === "" ? "" : Number(e.target.value))} + step={50} + disabled + /> +
+
+ +
+ Size: large +
+ + setValue4(e.target.value === "" ? "" : Number(e.target.value))} + step={50} + controlSize="large" + /> +
+ +
+ + setValue5(e.target.value === "" ? "" : Number(e.target.value))} + step={50} + controlSize="large" + disabled={true} + /> +
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 995bfdf50e..d189866218 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -153,6 +153,10 @@ const stories: Story[] = [ name: "Simple form", slug: "simple-form", }, + { + name: "Stepper", + slug: "stepper", + }, { name: "Textarea", slug: "textarea", diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index cf1aa86c41..f6dbcadafd 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1,21 +1,24 @@ -import type { Organization, Project } from "@trigger.dev/database"; +import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3"; +import type { Organization, Project, RuntimeEnvironmentType } from "@trigger.dev/database"; import { BillingClient, - type Limits, - type SetPlanBody, - type UsageSeriesParams, - type UsageResult, defaultMachine as defaultMachineFromPlatform, machines as machinesFromPlatform, - type MachineCode, - type UpdateBillingAlertsRequest, type BillingAlertsResult, + type Limits, + type MachineCode, type ReportUsageResult, - type ReportUsagePlan, + type SetPlanBody, + type UpdateBillingAlertsRequest, + type UsageResult, + type UsageSeriesParams, + type CurrentPlan, } from "@trigger.dev/platform"; import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; import { MemoryStore } from "@unkey/cache/stores"; +import { existsSync, readFileSync } from "node:fs"; import { redirect } from "remix-typedjson"; +import { z } from "zod"; import { env } from "~/env.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { createEnvironment } from "~/models/organization.server"; @@ -23,9 +26,7 @@ import { logger } from "~/services/logger.server"; import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder"; import { singleton } from "~/utils/singleton"; import { RedisCacheStore } from "./unkey/redisCacheStore.server"; -import { existsSync, readFileSync } from "node:fs"; -import { z } from "zod"; -import { MachinePresetName } from "@trigger.dev/core/v3"; +import { $replica } from "~/db.server"; function initializeClient() { if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) { @@ -254,6 +255,52 @@ export async function getLimit(orgId: string, limit: keyof Limits, fallback: num return fallback; } +export async function getDefaultEnvironmentConcurrencyLimit( + organizationId: string, + environmentType: RuntimeEnvironmentType +): Promise { + if (!client) { + const org = await $replica.organization.findFirst({ + where: { + id: organizationId, + }, + select: { + maximumConcurrencyLimit: true, + }, + }); + if (!org) throw new Error("Organization not found"); + return org.maximumConcurrencyLimit; + } + + const result = await client.currentPlan(organizationId); + if (!result.success) throw new Error("Error getting current plan"); + + const limit = getDefaultEnvironmentLimitFromPlan(environmentType, result); + if (!limit) throw new Error("No plan found"); + + return limit; +} + +export function getDefaultEnvironmentLimitFromPlan( + environmentType: RuntimeEnvironmentType, + plan: CurrentPlan +): number | undefined { + if (!plan.v3Subscription?.plan) return undefined; + + switch (environmentType) { + case "DEVELOPMENT": + return plan.v3Subscription.plan.limits.concurrentRuns.development; + case "STAGING": + return plan.v3Subscription.plan.limits.concurrentRuns.staging; + case "PREVIEW": + return plan.v3Subscription.plan.limits.concurrentRuns.preview; + case "PRODUCTION": + return plan.v3Subscription.plan.limits.concurrentRuns.production; + default: + return plan.v3Subscription.plan.limits.concurrentRuns.number; + } +} + export async function getCachedLimit(orgId: string, limit: keyof Limits, fallback: number) { return platformCache.limits.swr(`${orgId}:${limit}`, async () => { return getLimit(orgId, limit, fallback); @@ -297,62 +344,74 @@ export async function setPlan( opts?: { invalidateBillingCache?: (orgId: string) => void } ) { if (!client) { - throw redirectWithErrorMessage(callerPath, request, "Error setting plan"); + return redirectWithErrorMessage(callerPath, request, "Error setting plan", { + ephemeral: false, + }); } - try { - const result = await client.setPlan(organization.id, plan); + const [error, result] = await tryCatch(client.setPlan(organization.id, plan)); - if (!result) { - throw redirectWithErrorMessage(callerPath, request, "Error setting plan"); - } + if (error) { + return redirectWithErrorMessage(callerPath, request, error.message, { ephemeral: false }); + } - if (!result.success) { - throw redirectWithErrorMessage(callerPath, request, result.error); - } + if (!result) { + return redirectWithErrorMessage(callerPath, request, "Error setting plan", { + ephemeral: false, + }); + } - switch (result.action) { - case "free_connect_required": { - return redirect(result.connectUrl); - } - case "free_connected": { - if (result.accepted) { - // Invalidate billing cache since plan changed - opts?.invalidateBillingCache?.(organization.id); - return redirect(newProjectPath(organization, "You're on the Free plan.")); - } else { - return redirectWithErrorMessage( - callerPath, - request, - "Free tier unlock failed, your GitHub account is too new." - ); - } - } - case "create_subscription_flow_start": { - return redirect(result.checkoutUrl); - } - case "updated_subscription": { - // Invalidate billing cache since subscription changed + if (!result.success) { + return redirectWithErrorMessage(callerPath, request, result.error, { ephemeral: false }); + } + + switch (result.action) { + case "free_connect_required": { + return redirect(result.connectUrl); + } + case "free_connected": { + if (result.accepted) { + // Invalidate billing cache since plan changed opts?.invalidateBillingCache?.(organization.id); - return redirectWithSuccessMessage( + return redirect(newProjectPath(organization, "You're on the Free plan.")); + } else { + return redirectWithErrorMessage( callerPath, request, - "Subscription updated successfully." + "Free tier unlock failed, your GitHub account is too new.", + { ephemeral: false } ); } - case "canceled_subscription": { - // Invalidate billing cache since subscription was canceled - opts?.invalidateBillingCache?.(organization.id); - return redirectWithSuccessMessage(callerPath, request, "Subscription canceled."); - } } + case "create_subscription_flow_start": { + return redirect(result.checkoutUrl); + } + case "updated_subscription": { + // Invalidate billing cache since subscription changed + opts?.invalidateBillingCache?.(organization.id); + return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully."); + } + case "canceled_subscription": { + // Invalidate billing cache since subscription was canceled + opts?.invalidateBillingCache?.(organization.id); + return redirectWithSuccessMessage(callerPath, request, "Subscription canceled."); + } + } +} + +export async function setConcurrencyAddOn(organizationId: string, amount: number) { + if (!client) return undefined; + + try { + const result = await client.setAddOn(organizationId, { type: "concurrency", amount }); + if (!result.success) { + logger.error("Error setting concurrency add on - no success", { error: result.error }); + return undefined; + } + return result; } catch (e) { - logger.error("Error setting plan", { organizationId: organization.id, error: e }); - throw redirectWithErrorMessage( - callerPath, - request, - e instanceof Error ? e.message : "Error setting plan" - ); + logger.error("Error setting concurrency add on - caught error", { error: e }); + return undefined; } } @@ -462,7 +521,10 @@ export async function getEntitlement( } } -export async function projectCreated(organization: Organization, project: Project) { +export async function projectCreated( + organization: Pick, + project: Project +) { if (!isCloud()) { await createEnvironment({ organization, project, type: "STAGING" }); await createEnvironment({ diff --git a/apps/webapp/app/utils/environmentSort.ts b/apps/webapp/app/utils/environmentSort.ts index 00c7a33580..8b1709a2b0 100644 --- a/apps/webapp/app/utils/environmentSort.ts +++ b/apps/webapp/app/utils/environmentSort.ts @@ -12,10 +12,14 @@ type SortType = { userName?: string | null; }; -export function sortEnvironments(environments: T[]): T[] { +export function sortEnvironments( + environments: T[], + sortOrder?: RuntimeEnvironmentType[] +): T[] { + const order = sortOrder ?? environmentSortOrder; return environments.sort((a, b) => { - const aIndex = environmentSortOrder.indexOf(a.type); - const bIndex = environmentSortOrder.indexOf(b.type); + const aIndex = order.indexOf(a.type); + const bIndex = order.indexOf(b.type); const difference = aIndex - bIndex; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 4ad5680b20..f82165ae9d 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -463,6 +463,14 @@ export function branchesPath( return `${v3EnvironmentPath(organization, project, environment)}/branches`; } +export function concurrencyPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/concurrency`; +} + export function regionsPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/services/allocateConcurrency.server.ts b/apps/webapp/app/v3/services/allocateConcurrency.server.ts new file mode 100644 index 0000000000..83fb69d062 --- /dev/null +++ b/apps/webapp/app/v3/services/allocateConcurrency.server.ts @@ -0,0 +1,97 @@ +import { tryCatch } from "@trigger.dev/core"; +import { ManageConcurrencyPresenter } from "~/presenters/v3/ManageConcurrencyPresenter.server"; +import { BaseService } from "./baseService.server"; +import { updateEnvConcurrencyLimits } from "../runQueue.server"; + +type Input = { + userId: string; + projectId: string; + organizationId: string; + environments: { id: string; amount: number }[]; +}; + +type Result = + | { + success: true; + } + | { + success: false; + error: string; + }; + +export class AllocateConcurrencyService extends BaseService { + async call({ userId, projectId, organizationId, environments }: Input): Promise { + // fetch the current concurrency + const presenter = new ManageConcurrencyPresenter(this._prisma, this._replica); + const [error, result] = await tryCatch( + presenter.call({ + userId, + projectId, + organizationId, + }) + ); + + if (error) { + return { + success: false, + error: "Unknown error", + }; + } + + const previousExtra = result.environments.reduce( + (acc, e) => Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit) + acc, + 0 + ); + const requested = new Map(environments.map((e) => [e.id, e.amount])); + const newExtra = result.environments.reduce((acc, env) => { + const targetExtra = requested.has(env.id) + ? Math.max(0, requested.get(env.id)!) + : Math.max(0, env.maximumConcurrencyLimit - env.planConcurrencyLimit); + return acc + targetExtra; + }, 0); + const change = newExtra - previousExtra; + + const totalExtra = result.extraAllocatedConcurrency + change; + + if (change > result.extraUnallocatedConcurrency) { + return { + success: false, + error: `You don't have enough unallocated concurrency available. You requested ${totalExtra} but only have ${result.extraUnallocatedConcurrency}.`, + }; + } + + for (const environment of environments) { + const existingEnvironment = result.environments.find((e) => e.id === environment.id); + + if (!existingEnvironment) { + return { + success: false, + error: `Environment not found ${environment.id}`, + }; + } + + const newConcurrency = existingEnvironment.planConcurrencyLimit + environment.amount; + + const updatedEnvironment = await this._prisma.runtimeEnvironment.update({ + where: { + id: environment.id, + }, + data: { + maximumConcurrencyLimit: newConcurrency, + }, + include: { + project: true, + organization: true, + }, + }); + + if (!updatedEnvironment.paused) { + await updateEnvConcurrencyLimits(updatedEnvironment); + } + } + + return { + success: true, + }; + } +} diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index 32ca7910f1..41fbf2afe2 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -361,14 +361,7 @@ async function createWorkerQueue( const baseConcurrencyLimit = typeof queue.concurrencyLimit === "number" - ? Math.max( - Math.min( - queue.concurrencyLimit, - environment.maximumConcurrencyLimit, - environment.organization.maximumConcurrencyLimit - ), - 0 - ) + ? Math.max(Math.min(queue.concurrencyLimit, environment.maximumConcurrencyLimit), 0) : queue.concurrencyLimit; const taskQueue = await upsertWorkerQueueRecord( diff --git a/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts new file mode 100644 index 0000000000..66ac7cdf19 --- /dev/null +++ b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts @@ -0,0 +1,143 @@ +import { ManageConcurrencyPresenter } from "~/presenters/v3/ManageConcurrencyPresenter.server"; +import { BaseService } from "./baseService.server"; +import { tryCatch } from "@trigger.dev/core"; +import { setConcurrencyAddOn } from "~/services/platform.v3.server"; +import assertNever from "assert-never"; +import { sendToPlain } from "~/utils/plain.server"; +import { uiComponent } from "@team-plain/typescript-sdk"; + +type Input = { + userId: string; + projectId: string; + organizationId: string; + action: "purchase" | "quota-increase"; + amount: number; +}; + +type Result = + | { + success: true; + } + | { + success: false; + error: string; + }; + +export class SetConcurrencyAddOnService extends BaseService { + async call({ userId, projectId, organizationId, action, amount }: Input): Promise { + // fetch the current concurrency + const presenter = new ManageConcurrencyPresenter(this._prisma, this._replica); + const [error, result] = await tryCatch( + presenter.call({ + userId, + projectId, + organizationId, + }) + ); + + if (error) { + return { + success: false, + error: "Unknown error", + }; + } + + const currentConcurrency = result.extraConcurrency; + const totalExtraConcurrency = amount; + + switch (action) { + case "purchase": { + const updatedConcurrency = await setConcurrencyAddOn(organizationId, totalExtraConcurrency); + if (!updatedConcurrency) { + return { + success: false, + error: "Failed to update concurrency", + }; + } + + switch (updatedConcurrency?.result) { + case "success": { + return { + success: true, + }; + } + case "error": { + return { + success: false, + error: updatedConcurrency.error, + }; + } + case "max_quota_reached": { + return { + success: false, + error: `You can't purchase more than ${updatedConcurrency.maxQuota} concurrency without requesting an increase.`, + }; + } + default: { + return { + success: false, + error: "Failed to update concurrency, unknown result.", + }; + } + } + } + case "quota-increase": { + const user = await this._replica.user.findFirst({ + where: { id: userId }, + }); + + if (!user) { + return { + success: false, + error: "No matching user found.", + }; + } + + const organization = await this._replica.organization.findFirst({ + select: { + title: true, + }, + where: { id: organizationId }, + }); + + const [error, result] = await tryCatch( + sendToPlain({ + userId, + email: user.email, + name: user.name ?? user.displayName ?? user.email, + title: `Concurrency quota request: ${totalExtraConcurrency}`, + components: [ + uiComponent.text({ + text: `Org: ${organization?.title} (${organizationId})`, + }), + uiComponent.divider({ spacingSize: "M" }), + uiComponent.text({ + text: `Total concurrency (set this): ${totalExtraConcurrency}`, + }), + uiComponent.text({ + text: `Current extra concurrency: ${currentConcurrency}`, + }), + uiComponent.text({ + text: `Amount requested: ${amount}`, + }), + ], + }) + ); + + if (error) { + return { + success: false, + error: error.message, + }; + } + + return { + success: true, + }; + } + default: { + assertNever(action); + } + } + } +} diff --git a/apps/webapp/app/v3/services/triggerTaskV1.server.ts b/apps/webapp/app/v3/services/triggerTaskV1.server.ts index 9d414f5b43..efc6510ef3 100644 --- a/apps/webapp/app/v3/services/triggerTaskV1.server.ts +++ b/apps/webapp/app/v3/services/triggerTaskV1.server.ts @@ -476,8 +476,7 @@ export class TriggerTaskServiceV1 extends BaseService { ? Math.max( Math.min( body.options.queue.concurrencyLimit, - environment.maximumConcurrencyLimit, - environment.organization.maximumConcurrencyLimit + environment.maximumConcurrencyLimit ), 0 ) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 370080bc9f..5daffe8960 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -114,7 +114,7 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.0.19", + "@trigger.dev/platform": "1.0.20", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql b/internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql new file mode 100644 index 0000000000..9135c3a4c1 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."Organization" +ADD COLUMN "maximumProjectCount" INTEGER NOT NULL DEFAULT 10; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index c568c78208..eae19bc42b 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -204,6 +204,8 @@ model Organization { featureFlags Json? + maximumProjectCount Int @default(10) + projects Project[] members OrgMember[] invites OrgMemberInvite[] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e64aab9459..46463b0739 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,8 +463,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.19 - version: 1.0.19 + specifier: 1.0.20 + version: 1.0.20 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -19228,8 +19228,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@trigger.dev/platform@1.0.19: - resolution: {integrity: sha512-dA2FmEItCO3/7LHFkkw65OxVQQEystcL+7uCqVTMOvam7S0FR+x1qNSQ40XNqSN7W5gM/uky/IgTOfk0JmILOw==} + /@trigger.dev/platform@1.0.20: + resolution: {integrity: sha512-KyFAJFuUFxsRo/tQ+N4R1yQutdZ7DBIyjzqgNKjee2hjvozu7jZmXkFPaqVDvmUCqeK7UvfBCvjO3gUV+mNGag==} dependencies: zod: 3.23.8 dev: false