From 2e783a97f12147f960831bbbc3668d672d86f411 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 23 Oct 2025 10:06:56 +0100 Subject: [PATCH 01/33] Don't use the organization max concurrency anymore --- .../app/v3/services/createBackgroundWorker.server.ts | 9 +-------- apps/webapp/app/v3/services/triggerTaskV1.server.ts | 3 +-- 2 files changed, 2 insertions(+), 10 deletions(-) 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/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 ) From 8a0806b41d727d69efb96c5d7b133aaf9fc81986 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 23 Oct 2025 17:59:51 +0100 Subject: [PATCH 02/33] Early draft of the concurrency page --- .../app/assets/icons/ConcurrencyIcon.tsx | 13 ++ .../app/components/navigation/SideMenu.tsx | 12 + apps/webapp/app/models/organization.server.ts | 6 +- .../v3/ManageConcurrencyPresenter.server.ts | 95 ++++++++ .../route.tsx | 217 ++++++++++++++++++ ...$projectParam.env.$envParam.concurrency.ts | 9 - .../webapp/app/services/platform.v3.server.ts | 69 +++++- apps/webapp/app/utils/pathBuilder.ts | 8 + apps/webapp/package.json | 2 +- pnpm-lock.yaml | 8 +- 10 files changed, 412 insertions(+), 27 deletions(-) create mode 100644 apps/webapp/app/assets/icons/ConcurrencyIcon.tsx create mode 100644 apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx delete mode 100644 apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts 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/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 && ( + + )} { + // 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, + }, + 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 (environment.maximumConcurrencyLimit > limit) { + extraAllocatedConcurrency += environment.maximumConcurrencyLimit - limit; + } + + if (environment.projectId === projectId) { + projectEnvironments.push({ + id: environment.id, + type: environment.type, + isBranchableEnvironment: environment.isBranchableEnvironment, + branchName: environment.branchName, + parentEnvironmentId: environment.parentEnvironmentId, + maximumConcurrencyLimit: environment.maximumConcurrencyLimit, + planConcurrencyLimit: limit, + }); + } + } + + return { + canAddConcurrency, + extraConcurrency, + extraAllocatedConcurrency, + environments: sortEnvironments(projectEnvironments).reverse(), + }; + } +} 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..ff506d2a8e --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx @@ -0,0 +1,217 @@ +import { MetaFunction } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { + MainCenteredContainer, + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Header2 } from "~/components/primitives/Headers"; +import { InputGroup } from "~/components/primitives/InputGroup"; +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 { useOrganization } from "~/hooks/useOrganizations"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { + EnvironmentWithConcurrency, + ManageConcurrencyPresenter, +} from "~/presenters/v3/ManageConcurrencyPresenter.server"; +import { requireUser, requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { EnvironmentParamSchema, regionsPath, v3BillingPath } from "~/utils/pathBuilder"; +import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { useFeatures } from "~/hooks/useFeatures"; +import { LinkButton } from "~/components/primitives/Buttons"; + +export const meta: MetaFunction = () => { + return [ + { + title: `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, + }); + } + + return typedjson(result); +}; + +const FormSchema = z.object({ + regionId: z.string(), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const user = await requireUser(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + + const redirectPath = regionsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + + if (!project) { + throw redirectWithErrorMessage(redirectPath, request, "Project not found"); + } + + const formData = await request.formData(); + const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData)); + + if (!parsedFormData.success) { + throw redirectWithErrorMessage(redirectPath, request, "No region specified"); + } + + const service = new SetDefaultRegionService(); + const [error, result] = await tryCatch( + service.call({ + projectId: project.id, + regionId: parsedFormData.data.regionId, + isAdmin: user.admin || user.isImpersonating, + }) + ); + + if (error) { + return redirectWithErrorMessage(redirectPath, request, error.message); + } + + return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`); +}; + +export default function Page() { + const { canAddConcurrency, environments } = useTypedLoaderData(); + const organization = useOrganization(); + + return ( + + + + + + + {environments.map((environment) => ( + + + {environment.type}{" "} + {environment.branchName ? ` (${environment.branchName})` : ""} + + {environment.id} + + ))} + + + + + + + {canAddConcurrency ? ( +
+
+ Manage your concurrency +
+
+ +
+ +
+
+
+
+ ) : ( + + )} +
+
+
+ ); +} + +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} + + ))} + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts deleted file mode 100644 index e5af3479c6..0000000000 --- a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { EnvironmentParamSchema, v3QueuesPath } from "~/utils/pathBuilder"; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - return redirect( - v3QueuesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }) - ); -}; diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index cf1aa86c41..0333759161 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 } 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); 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/package.json b/apps/webapp/package.json index 5092e3fea7..d89a12b5e8 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-beta.0", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b711645e94..5c9e107f60 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-beta.0 + version: 1.0.20-beta.0 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -18230,8 +18230,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-beta.0: + resolution: {integrity: sha512-NXpnhixVksE/3+r9s0O7fzwRbioIVkTB38vWNV5Q0MHJpD4/6KJa46M8mMGBcQ6i3afNe0BZ4/f2YEqnykjlEA==} dependencies: zod: 3.23.8 dev: false From 97a21664d1ca34fb66a3e2d30fb531c68ccb75c6 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 24 Oct 2025 18:08:18 +0100 Subject: [PATCH 03/33] WIP adding a new stepper input component --- .../app/routes/storybook.stepper/route.tsx | 162 ++++++++++++++++++ apps/webapp/app/routes/storybook/route.tsx | 4 + 2 files changed, 166 insertions(+) create mode 100644 apps/webapp/app/routes/storybook.stepper/route.tsx 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..d03352388e --- /dev/null +++ b/apps/webapp/app/routes/storybook.stepper/route.tsx @@ -0,0 +1,162 @@ +import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { useState, useRef, type ChangeEvent } from "react"; +import { cn } from "~/utils/cn"; + +export default function Story() { + const [value1, setValue1] = useState(0); + const [value2, setValue2] = useState(100); + const [value3, setValue3] = useState(0); + + return ( +
+
+

InputStepper examples

+ +
+ + setValue1(Number(e.target.value))} + step={75} + /> +
+ +
+ + setValue2(Number(e.target.value))} + step={50} + max={1000} + /> +
+ +
+ + setValue3(Number(e.target.value))} + step={50} + disabled + /> +
+
+
+ ); +} + +interface InputStepperProps { + value: number; + onChange: (e: ChangeEvent) => void; + step?: number; + min?: number; + max?: number; + name?: string; + id?: string; + disabled?: boolean; + readOnly?: boolean; + className?: string; +} + +function InputStepper({ + value, + onChange, + step = 50, + min, + max, + name, + id, + disabled = false, + readOnly = false, + className, +}: InputStepperProps) { + const inputRef = useRef(null); + + const handleStepUp = () => { + if (!inputRef.current || disabled) return; + + inputRef.current.stepUp(); + + // Dispatch a native change event so the onChange handler is called + const event = new Event("change", { bubbles: true }); + inputRef.current.dispatchEvent(event); + }; + + const handleStepDown = () => { + if (!inputRef.current || disabled) return; + + inputRef.current.stepDown(); + + // Dispatch a native change event so the onChange handler is called + const event = new Event("change", { bubbles: true }); + inputRef.current.dispatchEvent(event); + }; + + const isMinDisabled = min !== undefined && value <= min; + const isMaxDisabled = max !== undefined && value >= max; + + return ( +
+ + +
+ {/* Minus Button */} + + + +
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 995bfdf50e..1d47651422 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -157,6 +157,10 @@ const stories: Story[] = [ name: "Textarea", slug: "textarea", }, + { + name: "Stepper", + slug: "stepper", + }, { sectionTitle: "Menus", name: "Select", From 2493178a77a11f537aea3d6b3cc2224614f86378 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Oct 2025 15:01:21 +0000 Subject: [PATCH 04/33] Move stepper to be alphabetical --- apps/webapp/app/routes/storybook/route.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 1d47651422..d189866218 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -153,14 +153,14 @@ const stories: Story[] = [ name: "Simple form", slug: "simple-form", }, - { - name: "Textarea", - slug: "textarea", - }, { name: "Stepper", slug: "stepper", }, + { + name: "Textarea", + slug: "textarea", + }, { sectionTitle: "Menus", name: "Select", From 67ce871e3ae45f903f0c6be1b8da83fa96287e0f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Oct 2025 15:01:36 +0000 Subject: [PATCH 05/33] When max value is reached, disabled the + button --- apps/webapp/app/routes/storybook.stepper/route.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/storybook.stepper/route.tsx b/apps/webapp/app/routes/storybook.stepper/route.tsx index d03352388e..518e3ba975 100644 --- a/apps/webapp/app/routes/storybook.stepper/route.tsx +++ b/apps/webapp/app/routes/storybook.stepper/route.tsx @@ -1,5 +1,6 @@ import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; import { useState, useRef, type ChangeEvent } from "react"; +import { Header2 } from "~/components/primitives/Headers"; import { cn } from "~/utils/cn"; export default function Story() { @@ -8,9 +9,9 @@ export default function Story() { const [value3, setValue3] = useState(0); return ( -
+
-

InputStepper examples

+ InputStepper
@@ -102,7 +103,7 @@ function InputStepper({ className={cn( "flex h-9 items-center rounded border border-charcoal-600 bg-tertiary transition hover:border-charcoal-550/80 hover:bg-charcoal-600/80", "has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0 has-[:focus-visible]:outline-text-link", - "has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50", + disabled && "cursor-not-allowed opacity-50", className )} > From fc1bb53bfd6d0d8084d2cd389f200c3a8d46735f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Oct 2025 16:29:57 +0000 Subject: [PATCH 06/33] Show placeholder if you delete all numbers --- .../app/routes/storybook.stepper/route.tsx | 118 +++++++++++++++--- 1 file changed, 103 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/routes/storybook.stepper/route.tsx b/apps/webapp/app/routes/storybook.stepper/route.tsx index 518e3ba975..50ce317bbe 100644 --- a/apps/webapp/app/routes/storybook.stepper/route.tsx +++ b/apps/webapp/app/routes/storybook.stepper/route.tsx @@ -4,9 +4,9 @@ import { Header2 } from "~/components/primitives/Headers"; import { cn } from "~/utils/cn"; export default function Story() { - const [value1, setValue1] = useState(0); - const [value2, setValue2] = useState(100); - const [value3, setValue3] = useState(0); + const [value1, setValue1] = useState(0); + const [value2, setValue2] = useState(100); + const [value3, setValue3] = useState(0); return (
@@ -17,19 +17,20 @@ export default function Story() { setValue1(Number(e.target.value))} + onChange={(e) => setValue1(e.target.value === "" ? "" : Number(e.target.value))} step={75} />
setValue2(Number(e.target.value))} + onChange={(e) => setValue2(e.target.value === "" ? "" : Number(e.target.value))} step={50} + min={0} max={1000} />
@@ -38,7 +39,7 @@ export default function Story() { setValue3(Number(e.target.value))} + onChange={(e) => setValue3(e.target.value === "" ? "" : Number(e.target.value))} step={50} disabled /> @@ -49,16 +50,18 @@ export default function Story() { } interface InputStepperProps { - value: number; + value: number | ""; onChange: (e: ChangeEvent) => void; step?: number; min?: number; max?: number; + round?: boolean; name?: string; id?: string; disabled?: boolean; readOnly?: boolean; className?: string; + placeholder?: string; } function InputStepper({ @@ -67,20 +70,29 @@ function InputStepper({ step = 50, min, max, + round = true, name, id, disabled = false, readOnly = false, className, + placeholder = "Type a number", }: InputStepperProps) { 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(); - - // Dispatch a native change event so the onChange handler is called const event = new Event("change", { bubbles: true }); inputRef.current.dispatchEvent(event); }; @@ -88,15 +100,65 @@ function InputStepper({ 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(); - - // Dispatch a native change event so the onChange handler is called const event = new Event("change", { bubbles: true }); inputRef.current.dispatchEvent(event); }; - const isMinDisabled = min !== undefined && value <= min; - const isMaxDisabled = max !== undefined && value >= max; + 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; + // HTML number input uses min as the step base when provided, otherwise 0 + 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); + // Ties go down + 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); + } 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} @@ -131,6 +217,7 @@ function InputStepper({
From c1ed3adf08b02a1c0889d9a61229b51efd87b090 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Oct 2025 17:29:55 +0000 Subject: [PATCH 09/33] Move stepper into its own component --- .../primitives/InputNumberStepper.tsx | 218 ++++++++++++ .../app/routes/storybook.stepper/route.tsx | 315 ++++-------------- 2 files changed, 275 insertions(+), 258 deletions(-) create mode 100644 apps/webapp/app/components/primitives/InputNumberStepper.tsx diff --git a/apps/webapp/app/components/primitives/InputNumberStepper.tsx b/apps/webapp/app/components/primitives/InputNumberStepper.tsx new file mode 100644 index 0000000000..77c7be3e53 --- /dev/null +++ b/apps/webapp/app/components/primitives/InputNumberStepper.tsx @@ -0,0 +1,218 @@ +import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { type ChangeEvent, useRef } from "react"; +import { cn } from "~/utils/cn"; + +type InputNumberStepperProps = JSX.IntrinsicElements["input"] & { + 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", +}: 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" + )} + /> + +
+ + + +
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook.stepper/route.tsx b/apps/webapp/app/routes/storybook.stepper/route.tsx index 52b6d402d1..3cbe8f2f63 100644 --- a/apps/webapp/app/routes/storybook.stepper/route.tsx +++ b/apps/webapp/app/routes/storybook.stepper/route.tsx @@ -1,276 +1,75 @@ -import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { useState, useRef, type ChangeEvent } from "react"; -import { Header2 } from "~/components/primitives/Headers"; -import { cn } from "~/utils/cn"; +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 (
-
- InputStepper - -
- - setValue1(e.target.value === "" ? "" : Number(e.target.value))} - step={75} - /> -
- -
- - setValue2(e.target.value === "" ? "" : Number(e.target.value))} - step={50} - min={0} - max={1000} - /> -
- +
- - setValue4(e.target.value === "" ? "" : Number(e.target.value))} - step={50} - controlSize="large" - /> + 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 + /> +
- - 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} + /> +
); } - -type InputStepperProps = JSX.IntrinsicElements["input"] & { - step?: number; - min?: number; - max?: number; - round?: boolean; - controlSize?: "base" | "large"; -}; - -function InputStepper({ - value, - onChange, - step = 50, - min, - max, - round = true, - controlSize = "base", - name, - id, - disabled = false, - readOnly = false, - className, - placeholder = "Type a number", -}: InputStepperProps) { - 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-1.5 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" - )} - /> - -
- - - -
-
- ); -} From 0a7b7bbdbb0c15313da73bd54ca769176ed2c672 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 27 Oct 2025 17:30:48 +0000 Subject: [PATCH 10/33] Work on showing the extra concurrency --- .../v3/ManageConcurrencyPresenter.server.ts | 15 +- .../route.tsx | 142 +++++++++++++++--- 2 files changed, 133 insertions(+), 24 deletions(-) diff --git a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts index 491e92067b..d328d5a104 100644 --- a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts @@ -8,6 +8,7 @@ export type ConcurrencyResult = { environments: EnvironmentWithConcurrency[]; extraConcurrency: number; extraAllocatedConcurrency: number; + extraUnallocatedConcurrency: number; }; export type EnvironmentWithConcurrency = { @@ -48,6 +49,11 @@ export class ManageConcurrencyPresenter extends BasePresenter { parentEnvironmentId: true, isBranchableEnvironment: true, maximumConcurrencyLimit: true, + orgMember: { + select: { + userId: true, + }, + }, }, where: { organizationId, @@ -73,6 +79,10 @@ export class ManageConcurrencyPresenter extends BasePresenter { } if (environment.projectId === projectId) { + if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) { + continue; + } + projectEnvironments.push({ id: environment.id, type: environment.type, @@ -85,10 +95,13 @@ export class ManageConcurrencyPresenter extends BasePresenter { } } + const extraAllocated = Math.min(extraConcurrency, extraAllocatedConcurrency); + return { canAddConcurrency, extraConcurrency, - extraAllocatedConcurrency, + extraAllocatedConcurrency: extraAllocated, + extraUnallocatedConcurrency: extraConcurrency - extraAllocated, environments: sortEnvironments(projectEnvironments).reverse(), }; } 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 index ff506d2a8e..6f9d977662 100644 --- 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 @@ -1,4 +1,5 @@ -import { MetaFunction } from "@remix-run/react"; +import { PlusIcon } from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -6,14 +7,12 @@ import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { - MainCenteredContainer, MainHorizontallyCenteredContainer, PageBody, PageContainer, } from "~/components/layout/AppLayout"; -import { Header2 } from "~/components/primitives/Headers"; -import { InputGroup } from "~/components/primitives/InputGroup"; -import { Label } from "~/components/primitives/Label"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Header2, Header3 } from "~/components/primitives/Headers"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; @@ -25,20 +24,20 @@ import { 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 { - EnvironmentWithConcurrency, + type ConcurrencyResult, + type EnvironmentWithConcurrency, ManageConcurrencyPresenter, } from "~/presenters/v3/ManageConcurrencyPresenter.server"; import { requireUser, requireUserId } from "~/services/session.server"; -import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, regionsPath, v3BillingPath } from "~/utils/pathBuilder"; import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { useFeatures } from "~/hooks/useFeatures"; -import { LinkButton } from "~/components/primitives/Buttons"; export const meta: MetaFunction = () => { return [ @@ -123,8 +122,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { canAddConcurrency, environments } = useTypedLoaderData(); - const organization = useOrganization(); + const { + canAddConcurrency, + extraConcurrency, + extraAllocatedConcurrency, + extraUnallocatedConcurrency, + environments, + } = useTypedLoaderData(); return ( @@ -149,18 +153,13 @@ export default function Page() { {canAddConcurrency ? ( -
-
- Manage your concurrency -
-
- -
- -
-
-
-
+ ) : ( )} @@ -170,6 +169,103 @@ export default function Page() { ); } +function Upgradable({ + canAddConcurrency, + extraConcurrency, + extraAllocatedConcurrency, + extraUnallocatedConcurrency, + environments, +}: ConcurrencyResult) { + const organization = useOrganization(); + + return ( +
+
+ 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 + + {extraAllocatedConcurrency} + + + + Unallocated concurrency + 0 ? "text-success" : "text-text-bright"} + > + {extraUnallocatedConcurrency} + + + +
+
+
+
+ Concurrency allocation +
+ + + + Environment + + + Included{" "} + + + + Extra concurrency + Total + + + + {environments.map((environment) => ( + + + + + {environment.planConcurrencyLimit} + + {Math.max( + 0, + environment.maximumConcurrencyLimit - environment.planConcurrencyLimit + )} + + {environment.maximumConcurrencyLimit} + + ))} + +
+
+
+
+ ); +} + function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) { const { isManagedCloud } = useFeatures(); const plan = useCurrentPlan(); From 5316b80ea15b153d700be70adc1b00ed51d69bb8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 29 Oct 2025 13:03:34 +0000 Subject: [PATCH 11/33] The purchase form styling and functionality (minus actually purchasing) --- .../primitives/InputNumberStepper.tsx | 2 + .../v3/ManageConcurrencyPresenter.server.ts | 16 ++- .../route.tsx | 133 ++++++++++++++++-- .../webapp/app/services/platform.v3.server.ts | 16 +++ apps/webapp/package.json | 2 +- pnpm-lock.yaml | 8 +- 6 files changed, 156 insertions(+), 21 deletions(-) diff --git a/apps/webapp/app/components/primitives/InputNumberStepper.tsx b/apps/webapp/app/components/primitives/InputNumberStepper.tsx index 77c7be3e53..2041eafcb5 100644 --- a/apps/webapp/app/components/primitives/InputNumberStepper.tsx +++ b/apps/webapp/app/components/primitives/InputNumberStepper.tsx @@ -24,6 +24,7 @@ export function InputNumberStepper({ readOnly = false, className, placeholder = "Type a number", + ...props }: InputNumberStepperProps) { const inputRef = useRef(null); @@ -176,6 +177,7 @@ export function InputNumberStepper({ // 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/presenters/v3/ManageConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts index d328d5a104..2ffe5900a1 100644 --- a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts @@ -1,5 +1,9 @@ import { type RuntimeEnvironmentType } from "@trigger.dev/database"; -import { getCurrentPlan, getDefaultEnvironmentLimitFromPlan } from "~/services/platform.v3.server"; +import { + getCurrentPlan, + getDefaultEnvironmentLimitFromPlan, + getPlans, +} from "~/services/platform.v3.server"; import { BasePresenter } from "./basePresenter.server"; import { sortEnvironments } from "~/utils/environmentSort"; @@ -9,6 +13,10 @@ export type ConcurrencyResult = { extraConcurrency: number; extraAllocatedConcurrency: number; extraUnallocatedConcurrency: number; + concurrencyPricing: { + stepSize: number; + centsPerStep: number; + }; }; export type EnvironmentWithConcurrency = { @@ -97,12 +105,18 @@ export class ManageConcurrencyPresenter extends BasePresenter { 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, environments: sortEnvironments(projectEnvironments).reverse(), + 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 index 6f9d977662..412f6fd657 100644 --- 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 @@ -1,7 +1,8 @@ import { PlusIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; +import { Form, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; +import { useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -12,7 +13,19 @@ import { PageContainer, } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; 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"; @@ -34,7 +47,9 @@ import { type EnvironmentWithConcurrency, ManageConcurrencyPresenter, } from "~/presenters/v3/ManageConcurrencyPresenter.server"; +import { getPlans } from "~/services/platform.v3.server"; import { requireUser, requireUserId } from "~/services/session.server"; +import { formatCurrency } from "~/utils/numberFormatter"; import { EnvironmentParamSchema, regionsPath, v3BillingPath } from "~/utils/pathBuilder"; import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; @@ -75,6 +90,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); } + const plans = await tryCatch(getPlans()); + if (!plans) { + throw new Response(null, { status: 404, statusText: "Plans not found" }); + } + return typedjson(result); }; @@ -128,6 +148,7 @@ export default function Page() { extraAllocatedConcurrency, extraUnallocatedConcurrency, environments, + concurrencyPricing, } = useTypedLoaderData(); return ( @@ -159,6 +180,7 @@ export default function Page() { extraAllocatedConcurrency={extraAllocatedConcurrency} extraUnallocatedConcurrency={extraUnallocatedConcurrency} environments={environments} + concurrencyPricing={concurrencyPricing} /> ) : ( @@ -175,6 +197,7 @@ function Upgradable({ extraAllocatedConcurrency, extraUnallocatedConcurrency, environments, + concurrencyPricing, }: ConcurrencyResult) { const organization = useOrganization(); @@ -192,20 +215,16 @@ function Upgradable({
Extra concurrency - +
- + - Extra concurrency purchased - + Extra concurrency purchased + {extraConcurrency} - + - - Allocated concurrency @@ -233,7 +252,7 @@ function Upgradable({ Environment - + Included{" "} @@ -250,10 +269,20 @@ function Upgradable({ {environment.planConcurrencyLimit} - {Math.max( - 0, - environment.maximumConcurrencyLimit - environment.planConcurrencyLimit - )} +
+ +
{environment.maximumConcurrencyLimit}
@@ -311,3 +340,77 @@ function NotUpgradable({ environments }: { environments: EnvironmentWithConcurre ); } + +function PurchaseConcurrencyModal({ + concurrencyPricing, +}: { + concurrencyPricing: { + stepSize: number; + centsPerStep: number; + }; +}) { + const [amount, setAmount] = useState(0); + + return ( + + + + + + Purchase extra concurrency +
+ + You can purchase bundles of {concurrencyPricing.stepSize} concurrency for + {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. You’ll be billed + monthly, with changes available after a full billing cycle. + +
+
+ + + setAmount(Number(e.target.value))} + /> + +
+ +
+
+ Summary + Total +
+
+ {amount} + + {formatCurrency( + (amount * concurrencyPricing.centsPerStep) / concurrencyPricing.stepSize / 100, + false + )} + +
+
+ + ({amount / concurrencyPricing.stepSize} bundles @{" "} + {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/mth) + + /mth +
+
+
+ + + +
+
+ ); +} diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 0333759161..4dcf86fad3 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -403,6 +403,22 @@ export async function setPlan( } } +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 concurrency add on - caught error", { error: e }); + return undefined; + } +} + export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) { if (!client) return undefined; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index d89a12b5e8..5474ccc427 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.20-beta.0", + "@trigger.dev/platform": "1.0.20-beta.2", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c9e107f60..837e3aa4c6 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.20-beta.0 - version: 1.0.20-beta.0 + specifier: 1.0.20-beta.2 + version: 1.0.20-beta.2 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -18230,8 +18230,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@trigger.dev/platform@1.0.20-beta.0: - resolution: {integrity: sha512-NXpnhixVksE/3+r9s0O7fzwRbioIVkTB38vWNV5Q0MHJpD4/6KJa46M8mMGBcQ6i3afNe0BZ4/f2YEqnykjlEA==} + /@trigger.dev/platform@1.0.20-beta.2: + resolution: {integrity: sha512-iRE056ez159I+lXixwjfWzgKSv2TyeS+ChSf+wfQ4b6rzCV7df13f8HKr4oI3O5sogI8qi9HYThLnCFbmB1gFw==} dependencies: zod: 3.23.8 dev: false From f3598d3a81a414773fb3d8bdcca39fc5a2c193fa Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 29 Oct 2025 13:52:16 +0000 Subject: [PATCH 12/33] New style for outline input fields --- .../app/components/primitives/Input.tsx | 18 +++++++ .../routes/storybook.input-fields/route.tsx | 47 ++----------------- 2 files changed, 21 insertions(+), 44 deletions(-) diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx index 5e96a0eb79..3364e48bed 100644 --- a/apps/webapp/app/components/primitives/Input.tsx +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -44,6 +44,24 @@ const variants = { iconSize: "size-3 ml-0.5", accessory: "pr-0.5", }, + "outline/large": { + container: "px-1 h-10 w-full rounded border border-grid-bright hover:border-charcoal-550", + input: "px-2 rounded text-sm", + iconSize: "size-4 ml-1", + accessory: "pr-1", + }, + "outline/medium": { + container: "px-1 h-8 w-full rounded border border-grid-bright hover:border-charcoal-550", + input: "px-1 rounded text-sm", + iconSize: "size-4 ml-0.5", + accessory: "pr-1", + }, + "outline/small": { + container: "px-1 h-6 w-full rounded border border-grid-bright hover:border-charcoal-550", + input: "px-1 rounded text-xs", + iconSize: "size-3 ml-0.5", + accessory: "pr-0.5", + }, }; export type InputProps = React.InputHTMLAttributes & { 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={} - /> -
); } From de4d809edb1b29e90ca73ee32ed17d833f8eeaac Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 30 Oct 2025 09:40:24 +0000 Subject: [PATCH 13/33] Concurrency purchasing working --- .../v3/ManageConcurrencyPresenter.server.ts | 2 + .../route.tsx | 220 ++++++++++++------ .../v3/services/setConcurrencyAddOn.server.ts | 94 ++++++++ 3 files changed, 247 insertions(+), 69 deletions(-) create mode 100644 apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts diff --git a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts index 2ffe5900a1..1208c3e22d 100644 --- a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts @@ -13,6 +13,7 @@ export type ConcurrencyResult = { extraConcurrency: number; extraAllocatedConcurrency: number; extraUnallocatedConcurrency: number; + maxQuota: number; concurrencyPricing: { stepSize: number; centsPerStep: number; @@ -115,6 +116,7 @@ export class ManageConcurrencyPresenter extends BasePresenter { extraConcurrency, extraAllocatedConcurrency: extraAllocated, extraUnallocatedConcurrency: extraConcurrency - extraAllocated, + maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0, environments: sortEnvironments(projectEnvironments).reverse(), 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 index 412f6fd657..680deee65d 100644 --- 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 @@ -1,6 +1,9 @@ -import { PlusIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useActionData, useNavigation, type MetaFunction } from "@remix-run/react"; +import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -13,14 +16,10 @@ import { PageContainer, } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTrigger, -} from "~/components/primitives/Dialog"; +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"; @@ -43,21 +42,22 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { + ManageConcurrencyPresenter, type ConcurrencyResult, type EnvironmentWithConcurrency, - ManageConcurrencyPresenter, } from "~/presenters/v3/ManageConcurrencyPresenter.server"; import { getPlans } from "~/services/platform.v3.server"; -import { requireUser, requireUserId } from "~/services/session.server"; -import { formatCurrency } from "~/utils/numberFormatter"; -import { EnvironmentParamSchema, regionsPath, v3BillingPath } from "~/utils/pathBuilder"; -import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.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"; export const meta: MetaFunction = () => { return [ { - title: `Concurrency | Trigger.dev`, + title: `Manage concurrency | Trigger.dev`, }, ]; }; @@ -99,16 +99,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; const FormSchema = z.object({ - regionId: z.string(), + action: z.enum(["purchase", "quota-increase"]), + amount: z.coerce.number().min(1, "Amount must be greater than 0"), }); export const action = async ({ request, params }: ActionFunctionArgs) => { - const user = await requireUser(request); + const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - const project = await findProjectBySlug(organizationSlug, projectParam, user.id); - - const redirectPath = regionsPath( + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const redirectPath = concurrencyPath( { slug: organizationSlug }, { slug: projectParam }, { slug: envParam } @@ -119,26 +119,34 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } const formData = await request.formData(); - const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData)); + const submission = parse(formData, { schema: FormSchema }); - if (!parsedFormData.success) { - throw redirectWithErrorMessage(redirectPath, request, "No region specified"); + if (!submission.value || submission.intent !== "submit") { + return json(submission); } - const service = new SetDefaultRegionService(); + const service = new SetConcurrencyAddOnService(); const [error, result] = await tryCatch( service.call({ + userId, projectId: project.id, - regionId: parsedFormData.data.regionId, - isAdmin: user.admin || user.isImpersonating, + organizationId: project.organizationId, + action: submission.value.action, + amount: submission.value.amount, }) ); if (error) { - return redirectWithErrorMessage(redirectPath, request, error.message); + submission.error.amount = [error instanceof Error ? error.message : "Unknown error"]; + return json(submission); } - return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`); + if (!result.success) { + submission.error.amount = [result.error]; + return json(submission); + } + + return redirectWithSuccessMessage(redirectPath, request, "Concurrency updated successfully"); }; export default function Page() { @@ -149,6 +157,7 @@ export default function Page() { extraUnallocatedConcurrency, environments, concurrencyPricing, + maxQuota, } = useTypedLoaderData(); return ( @@ -181,6 +190,7 @@ export default function Page() { extraUnallocatedConcurrency={extraUnallocatedConcurrency} environments={environments} concurrencyPricing={concurrencyPricing} + maxQuota={maxQuota} /> ) : ( @@ -198,6 +208,7 @@ function Upgradable({ extraUnallocatedConcurrency, environments, concurrencyPricing, + maxQuota, }: ConcurrencyResult) { const organization = useOrganization(); @@ -215,7 +226,11 @@ function Upgradable({
Extra concurrency - +
@@ -343,13 +358,34 @@ function NotUpgradable({ environments }: { environments: EnvironmentWithConcurre function PurchaseConcurrencyModal({ concurrencyPricing, + extraConcurrency, + maxQuota, }: { concurrencyPricing: { stepSize: number; centsPerStep: number; }; + extraConcurrency: number; + maxQuota: number; }) { - const [amount, setAmount] = useState(0); + 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(0); + const navigation = useNavigation(); + console.log(navigation); + const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST"; + + const maximum = maxQuota - extraConcurrency; + const isAboveMaxQuota = amountValue > maximum; return ( @@ -360,56 +396,102 @@ function PurchaseConcurrencyModal({ Purchase extra concurrency -
- - You can purchase bundles of {concurrencyPricing.stepSize} concurrency for - {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. You’ll be billed - monthly, with changes available after a full billing cycle. - -
+ +
+ + You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "} + {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. You’ll be billed + monthly, with changes available after a full billing cycle. +
setAmount(Number(e.target.value))} + value={amountValue} + onChange={(e) => setAmountValue(Number(e.target.value))} + disabled={isLoading} /> + {amount.error} + {form.error}
- -
-
- Summary - Total -
-
- {amount} - - {formatCurrency( - (amount * concurrencyPricing.centsPerStep) / concurrencyPricing.stepSize / 100, - false - )} - -
-
- - ({amount / concurrencyPricing.stepSize} bundles @{" "} - {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/mth) - - /mth -
+ {isAboveMaxQuota ? ( +
+ + Your Org’s total would be {formatNumber(extraConcurrency + amountValue)}{" "} + concurrency. Send us a request to purchase {formatNumber(amountValue - maximum)}{" "} + more, or reduce the amount to buy more today. + +
+ ) : ( +
+
+ Summary + Total +
+
+ {amountValue} + + {formatCurrency( + (amountValue * concurrencyPricing.centsPerStep) / + concurrencyPricing.stepSize / + 100, + false + )} + +
+
+ + ({amountValue / concurrencyPricing.stepSize} bundles @{" "} + {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/mth) + + /mth +
+
+ )}
-
- - - + + + + + ) : ( + <> + + + + ) + } + cancelButton={ + + + + } + /> +
); 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..9de89b01ba --- /dev/null +++ b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts @@ -0,0 +1,94 @@ +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"; + +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 = currentConcurrency + 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": { + return { + success: false, + error: "Quota increase is not supported yet.", + }; + break; + } + default: { + assertNever(action); + } + } + } +} From 98ecc090f630fa3c02de588bca5ff6dd220df4db Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 30 Oct 2025 11:57:29 +0000 Subject: [PATCH 14/33] Purchasing concurrency and quota emails working --- .../v3/ManageConcurrencyPresenter.server.ts | 7 ++- .../route.tsx | 26 ++++++--- apps/webapp/app/utils/environmentSort.ts | 10 +++- .../v3/services/setConcurrencyAddOn.server.ts | 55 ++++++++++++++++++- 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts index 1208c3e22d..b8b3ae801d 100644 --- a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts @@ -117,7 +117,12 @@ export class ManageConcurrencyPresenter extends BasePresenter { extraAllocatedConcurrency: extraAllocated, extraUnallocatedConcurrency: extraConcurrency - extraAllocated, maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0, - environments: sortEnvironments(projectEnvironments).reverse(), + 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 index 680deee65d..23906eb524 100644 --- 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 @@ -146,7 +146,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission); } - return redirectWithSuccessMessage(redirectPath, request, "Concurrency updated successfully"); + 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() { @@ -283,13 +289,13 @@ function Upgradable({ {environment.planConcurrencyLimit} - +
{isAboveMaxQuota ? (
+ + Currently you can only have up to {maxQuota} extra concurrency. This request for{" "} + {formatNumber(amountValue)} takes you to{" "} + {formatNumber(extraConcurrency + amountValue)} extra concurrency. + - Your Org’s total would be {formatNumber(extraConcurrency + amountValue)}{" "} - concurrency. Send us a request to purchase {formatNumber(amountValue - maximum)}{" "} - more, or reduce the amount to buy more today. + Send a request below to lift your current limit. We'll get back to you soon.
) : ( @@ -466,7 +474,7 @@ function PurchaseConcurrencyModal({ type="submit" disabled={isLoading} > - {`Send request for ${formatNumber(amountValue - maximum)}`} + {`Send request for ${formatNumber(extraConcurrency + amountValue)}`} ) : ( 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/v3/services/setConcurrencyAddOn.server.ts b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts index 9de89b01ba..39c47fed5f 100644 --- a/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts +++ b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts @@ -3,6 +3,8 @@ 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; @@ -80,11 +82,58 @@ export class SetConcurrencyAddOnService extends BaseService { } } 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: false, - error: "Quota increase is not supported yet.", + success: true, }; - break; } default: { assertNever(action); From 306a45d37b50cf0181aa2372a0cb1cf0ae4e9ab4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 2 Nov 2025 13:21:47 +0000 Subject: [PATCH 15/33] Improvements to the modal --- .../route.tsx | 104 ++++++++++++++---- .../v3/services/setConcurrencyAddOn.server.ts | 2 +- 2 files changed, 81 insertions(+), 25 deletions(-) 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 index 23906eb524..c0ca1c2af6 100644 --- 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 @@ -53,6 +53,7 @@ import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/ 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"; export const meta: MetaFunction = () => { return [ @@ -235,6 +236,7 @@ function Upgradable({
@@ -365,6 +367,7 @@ function NotUpgradable({ environments }: { environments: EnvironmentWithConcurre function PurchaseConcurrencyModal({ concurrencyPricing, extraConcurrency, + extraUnallocatedConcurrency, maxQuota, }: { concurrencyPricing: { @@ -372,6 +375,7 @@ function PurchaseConcurrencyModal({ centsPerStep: number; }; extraConcurrency: number; + extraUnallocatedConcurrency: number; maxQuota: number; }) { const lastSubmission = useActionData(); @@ -385,33 +389,40 @@ function PurchaseConcurrencyModal({ shouldRevalidate: "onSubmit", }); - const [amountValue, setAmountValue] = useState(0); + const [amountValue, setAmountValue] = useState(extraConcurrency); const navigation = useNavigation(); const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST"; - const maximum = maxQuota - extraConcurrency; - const isAboveMaxQuota = amountValue > maximum; + 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 ( - + - Purchase extra concurrency + {title}
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "} - {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. You’ll be billed - monthly, with changes available after a full billing cycle. + {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. Or you can + remove any extra concurrency after you have unallocated it from your environments + first.
{form.error}
- {isAboveMaxQuota ? ( + {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. This request for{" "} - {formatNumber(amountValue)} takes you to{" "} - {formatNumber(extraConcurrency + amountValue)} extra concurrency. + Currently you can only have up to {maxQuota} extra concurrency. This is a request + for {formatNumber(amountValue)}. Send a request below to lift your current limit. We'll get back to you soon. @@ -439,14 +458,16 @@ function PurchaseConcurrencyModal({ ) : (
- Summary - Total + Purchase + Cost
- {amountValue} - + + {formatNumber(amountValue - extraConcurrency)} + + {formatCurrency( - (amountValue * concurrencyPricing.centsPerStep) / + ((amountValue - extraConcurrency) * concurrencyPricing.centsPerStep) / concurrencyPricing.stepSize / 100, false @@ -455,7 +476,7 @@ function PurchaseConcurrencyModal({
- ({amountValue / concurrencyPricing.stepSize} bundles @{" "} + ({(amountValue - extraConcurrency) / concurrencyPricing.stepSize} bundles @{" "} {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/mth) /mth @@ -465,7 +486,7 @@ function PurchaseConcurrencyModal({
+ + ) : state === "decrease" || state === "need_to_increase_unallocated" ? ( + <> + + ) : ( @@ -483,10 +516,10 @@ function PurchaseConcurrencyModal({ ) @@ -504,3 +537,26 @@ function PurchaseConcurrencyModal({
); } + +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/v3/services/setConcurrencyAddOn.server.ts b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts index 39c47fed5f..66ac7cdf19 100644 --- a/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts +++ b/apps/webapp/app/v3/services/setConcurrencyAddOn.server.ts @@ -43,7 +43,7 @@ export class SetConcurrencyAddOnService extends BaseService { } const currentConcurrency = result.extraConcurrency; - const totalExtraConcurrency = currentConcurrency + amount; + const totalExtraConcurrency = amount; switch (action) { case "purchase": { From c906825563d25237105c06f6967205c15b416042 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 2 Nov 2025 13:39:36 +0000 Subject: [PATCH 16/33] Show cost breakdown in the modal --- .../route.tsx | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) 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 index c0ca1c2af6..9b5b63c4ac 100644 --- 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 @@ -448,36 +448,74 @@ function PurchaseConcurrencyModal({ ) : state === "above_quota" ? (
- Currently you can only have up to {maxQuota} extra concurrency. This is a request - for {formatNumber(amountValue)}. - - - Send a request below to lift your current limit. We'll get back to you soon. + 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.
) : (
- Purchase - Cost + 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, - false + true )}
({(amountValue - extraConcurrency) / concurrencyPricing.stepSize} bundles @{" "} - {formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/mth) + {formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth) + + /mth +
+
+ + {formatNumber(amountValue)} new total + + + {formatCurrency( + (amountValue * concurrencyPricing.centsPerStep) / + concurrencyPricing.stepSize / + 100, + true + )} + +
+
+ + ({amountValue / concurrencyPricing.stepSize} bundles) /mth
@@ -519,7 +557,7 @@ function PurchaseConcurrencyModal({ disabled={isLoading || state === "no_change"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Purchase ${formatNumber(amountValue - extraConcurrency)}`} + {`Purchase ${formatNumber(amountValue - extraConcurrency)} concurrency`} ) From b53c9194a6bf406aacc4aff8d4a74c04f1071e02 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 2 Nov 2025 16:29:33 +0000 Subject: [PATCH 17/33] Fix for allocated concurrency including DEV --- .../app/presenters/v3/ManageConcurrencyPresenter.server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts index b8b3ae801d..d6464fef62 100644 --- a/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ManageConcurrencyPresenter.server.ts @@ -83,10 +83,13 @@ export class ManageConcurrencyPresenter extends BasePresenter { : 0; if (!limit) continue; - if (environment.maximumConcurrencyLimit > limit) { + // 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; From 8ebb59caffbcc1f492da16e35f94434bc8725fa8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 2 Nov 2025 16:29:55 +0000 Subject: [PATCH 18/33] Improved types --- apps/webapp/app/components/primitives/InputNumberStepper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/InputNumberStepper.tsx b/apps/webapp/app/components/primitives/InputNumberStepper.tsx index 2041eafcb5..f4aafd5cae 100644 --- a/apps/webapp/app/components/primitives/InputNumberStepper.tsx +++ b/apps/webapp/app/components/primitives/InputNumberStepper.tsx @@ -2,7 +2,7 @@ import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; import { type ChangeEvent, useRef } from "react"; import { cn } from "~/utils/cn"; -type InputNumberStepperProps = JSX.IntrinsicElements["input"] & { +type InputNumberStepperProps = Omit & { step?: number; min?: number; max?: number; From 941dd37d2affab8595d035b20f8f05609ede66b3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 5 Nov 2025 15:28:25 -0800 Subject: [PATCH 19/33] Allocating concurrency is working --- .../route.tsx | 245 +++++++++++++++--- .../v3/services/allocateConcurrency.server.ts | 84 ++++++ 2 files changed, 297 insertions(+), 32 deletions(-) create mode 100644 apps/webapp/app/v3/services/allocateConcurrency.server.ts 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 index 9b5b63c4ac..0040eaf677 100644 --- 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 @@ -1,11 +1,23 @@ -import { conform, useForm } from "@conform-to/react"; +import { conform, useFieldList, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { + EnvelopeIcon, + ExclamationTriangleIcon, + InformationCircleIcon, + PlusIcon, +} from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useActionData, useNavigation, type MetaFunction } from "@remix-run/react"; +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 { useState } from "react"; +import { useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -54,6 +66,8 @@ import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.se 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 [ @@ -99,10 +113,22 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson(result); }; -const FormSchema = z.object({ - action: z.enum(["purchase", "quota-increase"]), - amount: z.coerce.number().min(1, "Amount must be greater than 0"), -}); +const FormSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.enum(["purchase", "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); @@ -126,6 +152,34 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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.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, + "Concurrency allocated successfully" + ); + } + const service = new SetConcurrencyAddOnService(); const [error, result] = await tryCatch( service.call({ @@ -187,7 +241,7 @@ export default function Page() { - + {canAddConcurrency ? ( ( + environments + .filter((e) => e.type !== "DEVELOPMENT") + .map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)]) + ) + ); + + const allocated = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0); + const unallocated = extraConcurrency - allocated; + const allocationModified = allocated !== extraAllocatedConcurrency; return (
- Your concurrency + Manage your concurrency
Concurrency limits determine how many runs you can execute at the same time. You can add @@ -238,6 +315,7 @@ function Upgradable({ extraConcurrency={extraConcurrency} extraUnallocatedConcurrency={extraUnallocatedConcurrency} maxQuota={maxQuota} + disabled={allocationModified} />
@@ -250,23 +328,83 @@ function Upgradable({ Allocated concurrency - - {extraAllocatedConcurrency} + + {allocationModified ? ( + <> + + {extraAllocatedConcurrency} + {" "} + {allocated} + + ) : ( + allocated + )} Unallocated concurrency 0 ? "text-success" : "text-text-bright"} + className={ + unallocated > 0 + ? "text-success" + : unallocated < 0 + ? "text-error" + : "text-text-bright" + } > - {extraUnallocatedConcurrency} + {allocationModified ? ( + <> + + {extraUnallocatedConcurrency} + {" "} + {unallocated} + + ) : ( + unallocated + )} + + + + +
+ {allocationModified ? ( + unallocated < 0 ? ( +
+ + + You're trying to allocate more concurrency than your total purchased + amount. + +
+ ) : ( +
+
+ + Save your changes or RESET +
+ +
+ ) + ) : ( + <> + )} +
-
+ +
Concurrency allocation
@@ -285,7 +423,7 @@ function Upgradable({ - {environments.map((environment) => ( + {environments.map((environment, index) => ( @@ -293,18 +431,34 @@ function Upgradable({ {environment.planConcurrencyLimit}
- + ) + ) : ( + <> + + { + const value = e.target.value === "" ? 0 : Number(e.target.value); + setAllocation(new Map(allocation).set(environment.id, value)); + }} + min={0} + /> + + )}
{environment.maximumConcurrencyLimit} @@ -312,7 +466,8 @@ function Upgradable({ ))}
-
+ {formEnvironments.error} +
); @@ -369,6 +524,7 @@ function PurchaseConcurrencyModal({ extraConcurrency, extraUnallocatedConcurrency, maxQuota, + disabled, }: { concurrencyPricing: { stepSize: number; @@ -377,6 +533,7 @@ function PurchaseConcurrencyModal({ extraConcurrency: number; extraUnallocatedConcurrency: number; maxQuota: number; + disabled: boolean; }) { const lastSubmission = useActionData(); const [form, { amount }] = useForm({ @@ -393,6 +550,21 @@ function PurchaseConcurrencyModal({ 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, @@ -405,9 +577,17 @@ function PurchaseConcurrencyModal({ const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency"; return ( - + - + {title} @@ -428,6 +608,7 @@ function PurchaseConcurrencyModal({ {...conform.input(amount, { type: "number" })} step={concurrencyPricing.stepSize} min={0} + max={undefined} value={amountValue} onChange={(e) => setAmountValue(Number(e.target.value))} disabled={isLoading} @@ -453,7 +634,7 @@ function PurchaseConcurrencyModal({
) : ( -
+
Summary Total 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..8ce3fa4deb --- /dev/null +++ b/apps/webapp/app/v3/services/allocateConcurrency.server.ts @@ -0,0 +1,84 @@ +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 totalExtra = environments.reduce((acc, e) => e.amount + acc, 0); + + if (totalExtra > 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, + }; + } +} From cdfb94c7772269da8cfea15d2d2d2c85b54d6333 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 5 Nov 2025 15:30:47 -0800 Subject: [PATCH 20/33] Live updates total env concurrency --- .../route.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 0040eaf677..caa4922793 100644 --- 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 @@ -461,7 +461,9 @@ function Upgradable({ )}
- {environment.maximumConcurrencyLimit} + + {environment.planConcurrencyLimit + (allocation.get(environment.id) ?? 0)} + ))} From 27a932541b1fad3ca381a226b9bfa373d6d496cc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 7 Nov 2025 15:29:11 +0000 Subject: [PATCH 21/33] Implemented reset --- .../route.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) 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 index caa4922793..9a433d45a4 100644 --- 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 @@ -262,6 +262,14 @@ export default function Page() { ); } +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 Upgradable({ extraConcurrency, extraAllocatedConcurrency, @@ -284,13 +292,7 @@ function Upgradable({ const navigation = useNavigation(); const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST"; - const [allocation, setAllocation] = useState( - new Map( - environments - .filter((e) => e.type !== "DEVELOPMENT") - .map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)]) - ) - ); + const [allocation, setAllocation] = useState(initialAllocation(environments)); const allocated = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0); const unallocated = extraConcurrency - allocated; @@ -380,8 +382,19 @@ function Upgradable({ ) : (
- - Save your changes or RESET + + + Save your changes or{" "} + + . +
@@ -489,7 +490,6 @@ function Upgradable({ ))} - {formEnvironments.error}
From 785814d3b0e43609ebe5b9c816941f928e72854e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 10 Nov 2025 12:18:53 +0000 Subject: [PATCH 25/33] Fixes for allocating concurrency where it didn't calculate correctly --- .../route.tsx | 2 +- .../app/v3/services/allocateConcurrency.server.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) 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 index 9c2e251701..774e1cc59d 100644 --- 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 @@ -301,8 +301,8 @@ function Upgradable({ const allocatedInProject = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0); const initialAllocationInProject = allocationTotal(environments); - const unallocated = extraConcurrency - allocatedInProject; const changeInAllocation = allocatedInProject - initialAllocationInProject; + const unallocated = extraUnallocatedConcurrency - changeInAllocation; const allocationModified = changeInAllocation !== 0; return ( diff --git a/apps/webapp/app/v3/services/allocateConcurrency.server.ts b/apps/webapp/app/v3/services/allocateConcurrency.server.ts index 8ce3fa4deb..aa6e68954c 100644 --- a/apps/webapp/app/v3/services/allocateConcurrency.server.ts +++ b/apps/webapp/app/v3/services/allocateConcurrency.server.ts @@ -38,9 +38,16 @@ export class AllocateConcurrencyService extends BaseService { }; } - const totalExtra = environments.reduce((acc, e) => e.amount + acc, 0); + const previousExtra = result.environments.reduce( + (acc, e) => Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit) + acc, + 0 + ); + const newExtra = environments.reduce((acc, e) => e.amount + acc, 0); + const change = newExtra - previousExtra; + + const totalExtra = result.extraAllocatedConcurrency + change; - if (totalExtra > result.extraUnallocatedConcurrency) { + if (change > result.extraUnallocatedConcurrency) { return { success: false, error: `You don't have enough unallocated concurrency available. You requested ${totalExtra} but only have ${result.extraUnallocatedConcurrency}.`, From ef862398170540f6787d145bf75aac2a49a161a5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 10 Nov 2025 12:36:46 +0000 Subject: [PATCH 26/33] "Increase limit" link to concurrency page --- .../route.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) 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 + ) : ( Date: Mon, 10 Nov 2025 12:37:56 +0000 Subject: [PATCH 27/33] Indent environments --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 774e1cc59d..ed91488c27 100644 --- 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 @@ -447,7 +447,7 @@ function Upgradable({ {environments.map((environment, index) => ( - + {environment.planConcurrencyLimit} From d95dc53511991b9f93c023d2176ca626524559b4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 12 Nov 2025 15:45:05 +0000 Subject: [PATCH 28/33] Added Preview limit when updating concurrency for an org --- .../app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts index d6491bcc45..d6eee08f37 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts @@ -74,6 +74,7 @@ export async function action({ request, params }: ActionFunctionArgs) { limit = body.development; break; } + case "PREVIEW": case "STAGING": { limit = body.staging; break; From aa63a330967fe10c149bf05c1e2fb49a798e5b97 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 13 Nov 2025 11:36:14 +0000 Subject: [PATCH 29/33] Show error when changing plan fails --- ...ces.orgs.$organizationSlug.select-plan.tsx | 2 +- .../webapp/app/services/platform.v3.server.ts | 88 +++++++++---------- 2 files changed, 43 insertions(+), 47 deletions(-) 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..0c6343c960 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -153,7 +153,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), }); } diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 4dcf86fad3..18dec7c5ff 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1,4 +1,4 @@ -import { MachinePresetName } from "@trigger.dev/core/v3"; +import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3"; import type { Organization, Project, RuntimeEnvironmentType } from "@trigger.dev/database"; import { BillingClient, @@ -344,62 +344,58 @@ 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."); - } } - } catch (e) { - logger.error("Error setting plan", { organizationId: organization.id, error: e }); - throw redirectWithErrorMessage( - callerPath, - request, - e instanceof Error ? e.message : "Error setting plan" - ); + 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."); + } } } From d0f36826a9364a44d9374e14442f593ec8c0b8d3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 13 Nov 2025 15:24:12 +0000 Subject: [PATCH 30/33] Added maximumProjectCount column to Org --- .../20251113152235_maximum_project_count/migration.sql | 3 +++ internal-packages/database/prisma/schema.prisma | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20251113152235_maximum_project_count/migration.sql 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[] From bef88f621b463cd5b555d14d5dd9bdc5e041a7d1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 13 Nov 2025 16:49:54 +0000 Subject: [PATCH 31/33] Limit project count and display a rich error toast (with title and button now) --- .../app/components/primitives/Toast.tsx | 81 ++++++++++++++++--- apps/webapp/app/models/message.server.ts | 24 +++++- apps/webapp/app/models/project.server.ts | 27 +++++++ .../route.tsx | 30 ++++++- .../routes/api.v1.orgs.$orgParam.projects.ts | 19 +++-- .../webapp/app/services/platform.v3.server.ts | 5 +- 6 files changed, 164 insertions(+), 22 deletions(-) diff --git a/apps/webapp/app/components/primitives/Toast.tsx b/apps/webapp/app/components/primitives/Toast.tsx index 21256e89ce..c9ca4c33d7 100644 --- a/apps/webapp/app/components/primitives/Toast.tsx +++ b/apps/webapp/app/components/primitives/Toast.tsx @@ -1,12 +1,17 @@ -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 { type loader } from "~/root"; import { useEffect } from "react"; import { Paragraph } from "./Paragraph"; import { cn } from "~/utils/cn"; +import { type ToastMessageAction } from "~/models/message.server"; +import { Header2, Header3 } from "./Headers"; +import { Button, LinkButton } from "./Buttons"; +import { Feedback } from "../Feedback"; +import assertNever from "assert-never"; +import { assertExhaustive } from "@trigger.dev/core"; const defaultToastDuration = 5000; const permanentToastDuration = 60 * 60 * 24 * 1000; @@ -19,9 +24,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 +50,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 }: { action?: ToastMessageAction; toastId: string }) { + if (!action) return null; + + switch (action.action.type) { + case "link": { + return ( + + {action.label} + + ); + } + case "help": { + return ( + { + e.preventDefault(); + toast.dismiss(toastId); + }} + > + {action.label} + + } + /> + ); + } + default: { + return null; + } + } +} 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/project.server.ts b/apps/webapp/app/models/project.server.ts index 10a2f0c02a..4b4d9f54a0 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( + `Organization ${organization.slug} has reached the maximum number of projects (${organization.maximumProjectCount}). You can request more by contacting help in the bottom-left.` + ); + } + //ensure the slug is globally unique const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`; const projectWithSameSlug = await prisma.project.findFirst({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index 9b292cb5ad..97a94fbe9c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -21,10 +21,11 @@ import { Label } from "~/components/primitives/Label"; import { ButtonSpinner } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; -import { redirectWithSuccessMessage } from "~/models/message.server"; -import { createProject } from "~/models/project.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { createProject, ExceededProjectLimitError } from "~/models/project.server"; import { requireUserId } from "~/services/session.server"; import { + newProjectPath, OrganizationParamsSchema, organizationPath, selectPlanPath, @@ -114,8 +115,29 @@ export const action: ActionFunction = async ({ request, params }) => { 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 } + ); } }; diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index 9a23d12909..dc1791dabc 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts @@ -4,6 +4,7 @@ import { CreateProjectRequestBody, GetProjectResponseBody, GetProjectsResponseBody, + tryCatch, } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; @@ -99,12 +100,18 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Invalid request body" }, { status: 400 }); } - const project = await createProject({ - organizationSlug: organization.slug, - name: parsedBody.data.name, - userId: authenticationResult.userId, - version: "v3", - }); + const [error, project] = await tryCatch( + createProject({ + organizationSlug: organization.slug, + name: parsedBody.data.name, + userId: authenticationResult.userId, + version: "v3", + }) + ); + + if (error) { + return json({ error: error.message }, { status: 400 }); + } const result: GetProjectResponseBody = { id: project.id, diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 18dec7c5ff..f6dbcadafd 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -521,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({ From f54b3dff3094c6ed7230cdee6b8abb3e3daadba9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 13 Nov 2025 18:22:30 +0000 Subject: [PATCH 32/33] Added title and button to toasts. Use it for new project error --- apps/webapp/app/components/Feedback.tsx | 26 ++++++- .../app/components/primitives/Toast.tsx | 71 +++++++++++-------- apps/webapp/app/models/project.server.ts | 2 +- .../route.tsx | 2 + 4 files changed, 66 insertions(+), 35 deletions(-) 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/primitives/Toast.tsx b/apps/webapp/app/components/primitives/Toast.tsx index c9ca4c33d7..5d8b002c49 100644 --- a/apps/webapp/app/components/primitives/Toast.tsx +++ b/apps/webapp/app/components/primitives/Toast.tsx @@ -1,17 +1,15 @@ import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; -import { Toaster, toast } from "sonner"; +import { useSearchParams } from "@remix-run/react"; +import { useEffect } from "react"; import { useTypedLoaderData } from "remix-typedjson"; +import { Toaster, toast } from "sonner"; +import { type ToastMessageAction } from "~/models/message.server"; import { type loader } from "~/root"; -import { useEffect } from "react"; -import { Paragraph } from "./Paragraph"; import { cn } from "~/utils/cn"; -import { type ToastMessageAction } from "~/models/message.server"; -import { Header2, Header3 } from "./Headers"; import { Button, LinkButton } from "./Buttons"; -import { Feedback } from "../Feedback"; -import assertNever from "assert-never"; -import { assertExhaustive } from "@trigger.dev/core"; +import { Header2 } from "./Headers"; +import { Paragraph } from "./Paragraph"; const defaultToastDuration = 5000; const permanentToastDuration = 60 * 60 * 24 * 1000; @@ -78,14 +76,14 @@ export function ToastUI({ )}
- {title && {title}} - + {title && {title}} + {message} - +
- } - /> + ); } - default: { - return null; - } } } diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 4b4d9f54a0..736df96ba1 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -63,7 +63,7 @@ export async function createProject( if (projectCount >= organization.maximumProjectCount) { throw new ExceededProjectLimitError( - `Organization ${organization.slug} has reached the maximum number of projects (${organization.maximumProjectCount}). You can request more by contacting help in the bottom-left.` + `This organization has reached the maximum number of projects (${organization.maximumProjectCount}).` ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index 97a94fbe9c..68c3306e28 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -8,6 +8,7 @@ import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; import { BackgroundWrapper } from "~/components/BackgroundWrapper"; +import { Feedback } from "~/components/Feedback"; import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; @@ -213,6 +214,7 @@ export default function Page() {
+ } /> From 8d2dbbc1305f93bf8d15ea7466cb6315e498747c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 13 Nov 2025 19:03:24 +0000 Subject: [PATCH 33/33] @trigger.dev/platform 1.0.20 --- apps/webapp/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 5474ccc427..0e4c23068b 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.20-beta.2", + "@trigger.dev/platform": "1.0.20", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 837e3aa4c6..2d5f5e4b2f 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.20-beta.2 - version: 1.0.20-beta.2 + specifier: 1.0.20 + version: 1.0.20 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -18230,8 +18230,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@trigger.dev/platform@1.0.20-beta.2: - resolution: {integrity: sha512-iRE056ez159I+lXixwjfWzgKSv2TyeS+ChSf+wfQ4b6rzCV7df13f8HKr4oI3O5sogI8qi9HYThLnCFbmB1gFw==} + /@trigger.dev/platform@1.0.20: + resolution: {integrity: sha512-KyFAJFuUFxsRo/tQ+N4R1yQutdZ7DBIyjzqgNKjee2hjvozu7jZmXkFPaqVDvmUCqeK7UvfBCvjO3gUV+mNGag==} dependencies: zod: 3.23.8 dev: false