From 9c13ad146fe566e33a7b35d747b90daaf8a2cded Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 Jan 2026 13:33:09 +0000 Subject: [PATCH 01/37] Very early Limits page --- .../app/components/navigation/SideMenu.tsx | 9 + .../presenters/v3/LimitsPresenter.server.ts | 316 +++++++++++++ .../route.tsx | 428 ++++++++++++++++++ apps/webapp/app/utils/pathBuilder.ts | 8 + 4 files changed, 761 insertions(+) create mode 100644 apps/webapp/app/presenters/v3/LimitsPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 76c974d596..c6a4bf209e 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1,4 +1,5 @@ import { + AdjustmentsHorizontalIcon, ArrowPathRoundedSquareIcon, ArrowRightOnRectangleIcon, BeakerIcon, @@ -50,6 +51,7 @@ import { adminPath, branchesPath, concurrencyPath, + limitsPath, logoutPath, newOrganizationPath, newProjectPath, @@ -362,6 +364,13 @@ export function SideMenu({ data-action="regions" badge={} /> + { + // Get organization with all limit-related fields + const organization = await this._replica.organization.findUniqueOrThrow({ + where: { id: organizationId }, + select: { + id: true, + maximumConcurrencyLimit: true, + maximumProjectCount: true, + maximumDevQueueSize: true, + maximumDeployedQueueSize: true, + apiRateLimiterConfig: true, + batchRateLimitConfig: true, + batchQueueConcurrencyConfig: true, + _count: { + select: { + projects: { + where: { deletedAt: null }, + }, + }, + }, + }, + }); + + // Get current plan from billing service + const currentPlan = await getCurrentPlan(organizationId); + + // Get environments for this project + const environments = await this._replica.runtimeEnvironment.findMany({ + select: { + id: true, + type: true, + branchName: true, + maximumConcurrencyLimit: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + where: { + projectId, + archivedAt: null, + }, + }); + + // Get current concurrency for each environment + const concurrencyLimits: ConcurrencyLimitInfo[] = []; + for (const environment of environments) { + // Skip dev environments that belong to other users + if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) { + continue; + } + + const planLimit = currentPlan + ? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan) ?? + env.DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT + : env.DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT; + + // Get current concurrency from Redis + let currentUsage = 0; + try { + currentUsage = await engine.runQueue.currentConcurrencyOfEnvironment({ + id: environment.id, + type: environment.type, + organizationId, + projectId, + }); + } catch (e) { + // Redis might not be available, default to 0 + } + + // Determine source + let source: "default" | "plan" | "override" = "default"; + if (environment.maximumConcurrencyLimit !== planLimit) { + source = "override"; + } else if (currentPlan?.v3Subscription?.plan) { + source = "plan"; + } + + concurrencyLimits.push({ + environmentId: environment.id, + environmentType: environment.type, + branchName: environment.branchName, + limit: environment.maximumConcurrencyLimit, + currentUsage, + planLimit, + source, + }); + } + + // Sort environments + const sortedConcurrencyLimits = sortEnvironments(concurrencyLimits, [ + "PRODUCTION", + "STAGING", + "PREVIEW", + "DEVELOPMENT", + ]); + + // Resolve API rate limit config + const apiRateLimitConfig = resolveApiRateLimitConfig(organization.apiRateLimiterConfig); + const apiRateLimitSource = organization.apiRateLimiterConfig ? "override" : "default"; + + // Resolve batch rate limit config + const batchRateLimitConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig); + const batchRateLimitSource = organization.batchRateLimitConfig ? "override" : "default"; + + // Resolve batch concurrency config + const batchConcurrencyConfig = resolveBatchConcurrencyConfig( + organization.batchQueueConcurrencyConfig + ); + const batchConcurrencySource = organization.batchQueueConcurrencyConfig + ? "override" + : "default"; + + // Get schedule count for this org + const scheduleCount = await this._replica.taskSchedule.count({ + where: { + instances: { + some: { + environment: { + organizationId, + }, + }, + }, + }, + }); + + // Get plan-level schedule limit + const schedulesLimit = currentPlan?.v3Subscription?.plan?.limits?.schedules?.number ?? null; + + return { + rateLimits: { + api: { + name: "API Rate Limit", + description: "Rate limit for API requests (trigger, batch, etc.)", + config: apiRateLimitConfig, + source: apiRateLimitSource, + }, + batch: { + name: "Batch Rate Limit", + description: "Rate limit for batch trigger operations", + config: batchRateLimitConfig, + source: batchRateLimitSource, + }, + }, + concurrencyLimits: sortedConcurrencyLimits, + quotas: { + projects: { + name: "Projects", + description: "Maximum number of projects in this organization", + limit: organization.maximumProjectCount, + currentUsage: organization._count.projects, + source: "default", + }, + schedules: + schedulesLimit !== null + ? { + name: "Schedules", + description: "Maximum number of schedules across all projects", + limit: schedulesLimit, + currentUsage: scheduleCount, + source: "plan", + } + : null, + devQueueSize: { + name: "Dev Queue Size", + description: "Maximum pending runs in development environments", + limit: organization.maximumDevQueueSize ?? null, + currentUsage: 0, // Would need to query Redis for this + source: organization.maximumDevQueueSize ? "override" : "default", + }, + deployedQueueSize: { + name: "Deployed Queue Size", + description: "Maximum pending runs in deployed environments", + limit: organization.maximumDeployedQueueSize ?? null, + currentUsage: 0, // Would need to query Redis for this + source: organization.maximumDeployedQueueSize ? "override" : "default", + }, + }, + batchConcurrency: { + limit: batchConcurrencyConfig.processingConcurrency, + source: batchConcurrencySource, + }, + planName: currentPlan?.v3Subscription?.plan?.title ?? null, + }; + } +} + +function resolveApiRateLimitConfig(apiRateLimiterConfig?: unknown): RateLimiterConfig { + const defaultConfig: RateLimitTokenBucketConfig = { + type: "tokenBucket", + refillRate: env.API_RATE_LIMIT_REFILL_RATE, + interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.API_RATE_LIMIT_MAX, + }; + + if (!apiRateLimiterConfig) { + return defaultConfig; + } + + const parsed = RateLimiterConfig.safeParse(apiRateLimiterConfig); + if (!parsed.success) { + return defaultConfig; + } + + return parsed.data; +} + +function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig { + const defaultConfig: RateLimitTokenBucketConfig = { + type: "tokenBucket", + refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE, + interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.BATCH_RATE_LIMIT_MAX, + }; + + if (!batchRateLimitConfig) { + return defaultConfig; + } + + const parsed = RateLimiterConfig.safeParse(batchRateLimitConfig); + if (!parsed.success) { + return defaultConfig; + } + + return parsed.data; +} + +function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): { + processingConcurrency: number; +} { + const defaultConfig = { + processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT, + }; + + if (!batchConcurrencyConfig) { + return defaultConfig; + } + + if (typeof batchConcurrencyConfig === "object" && batchConcurrencyConfig !== null) { + const config = batchConcurrencyConfig as Record; + if (typeof config.processingConcurrency === "number") { + return { processingConcurrency: config.processingConcurrency }; + } + } + + return defaultConfig; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx new file mode 100644 index 0000000000..913a31a963 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -0,0 +1,428 @@ +import { InformationCircleIcon } from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core"; +import { motion, useMotionValue, useTransform } from "framer-motion"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; +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"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { findProjectBySlug } from "~/models/project.server"; +import { + LimitsPresenter, + type LimitsResult, + type RateLimitInfo, + type ConcurrencyLimitInfo, + type QuotaInfo, +} from "~/presenters/v3/LimitsPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { formatNumber } from "~/utils/numberFormatter"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Limits | Trigger.dev`, + }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = 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 LimitsPresenter(); + const [error, result] = await tryCatch( + presenter.call({ + userId, + projectId: project.id, + organizationId: project.organizationId, + }) + ); + + if (error) { + throw new Response(error.message, { + status: 400, + }); + } + + return typedjson(result); +}; + +export default function Page() { + const data = useTypedLoaderData(); + + // Auto-revalidate every 5 seconds to get fresh concurrency data + useAutoRevalidate({ interval: 5000 }); + + return ( + + + + + + + + Plan + {data.planName ?? "No plan"} + + + + + + + +
+ {/* Plan info */} + {data.planName && ( +
+ + Current plan: + + {data.planName} + + (Limits refresh automatically every 5 seconds) + +
+ )} + + {/* Concurrency Limits Section */} + + + {/* Rate Limits Section */} + + + {/* Quotas Section */} + +
+
+
+
+ ); +} + +function ConcurrencyLimitsSection({ limits }: { limits: ConcurrencyLimitInfo[] }) { + return ( +
+
+ Concurrency Limits +
+ + Concurrency limits determine how many runs can execute at the same time in each environment. + The current usage updates in real-time. + + + + + Environment + + + Limit + + + + + + Current + + + + Usage + Source + + + + {limits.map((limit) => ( + + ))} + +
+
+ ); +} + +function ConcurrencyLimitRow({ limit }: { limit: ConcurrencyLimitInfo }) { + const percentage = limit.limit > 0 ? limit.currentUsage / limit.limit : 0; + const cappedPercentage = Math.min(percentage, 1); + + return ( + + + + + + {formatNumber(limit.limit)} + + + {formatNumber(limit.currentUsage)} + + + + + + + + + ); +} + +function RateLimitsSection({ rateLimits }: { rateLimits: LimitsResult["rateLimits"] }) { + return ( +
+
+ Rate Limits +
+ + Rate limits control how many API requests can be made within a time window. These use a + token bucket algorithm that refills tokens over time. + + + + + Rate Limit + Type + Configuration + Source + + + + + + +
+
+ ); +} + +function RateLimitRow({ info }: { info: RateLimitInfo }) { + return ( + + +
+ {info.name} + {info.description} +
+
+ + {info.config.type} + + + + + + + +
+ ); +} + +function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) { + if (config.type === "tokenBucket") { + return ( +
+ + Max tokens:{" "} + {formatNumber(config.maxTokens)} + + + Refill:{" "} + + {formatNumber(config.refillRate)}/{config.interval} + + +
+ ); + } + + if (config.type === "fixedWindow" || config.type === "slidingWindow") { + return ( +
+ + Tokens:{" "} + {formatNumber(config.tokens)} + + + Window:{" "} + {config.window} + +
+ ); + } + + return ; +} + +function QuotasSection({ + quotas, + batchConcurrency, +}: { + quotas: LimitsResult["quotas"]; + batchConcurrency: LimitsResult["batchConcurrency"]; +}) { + return ( +
+
+ Quotas +
+ + Quotas define the maximum resources available to your organization. + + + + + Quota + Limit + Current + Source + + + + + {quotas.schedules && } + + +
+ Batch Processing Concurrency + + Concurrent batch items being processed + +
+
+ + {formatNumber(batchConcurrency.limit)} + + + – + + + + +
+ {quotas.devQueueSize.limit !== null && } + {quotas.deployedQueueSize.limit !== null && ( + + )} +
+
+
+ ); +} + +function QuotaRow({ quota }: { quota: QuotaInfo }) { + const percentage = quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : 0; + const isAtLimit = percentage >= 1; + + return ( + + +
+ {quota.name} + {quota.description} +
+
+ + {quota.limit !== null ? formatNumber(quota.limit) : "Unlimited"} + + + {formatNumber(quota.currentUsage)} + + + + +
+ ); +} + +function UsageBar({ percentage }: { percentage: number }) { + const widthProgress = useMotionValue(percentage * 100); + const color = useTransform( + widthProgress, + [0, 74, 75, 95, 100], + ["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"] + ); + + return ( +
+
+ +
+ + {Math.round(percentage * 100)}% + +
+ ); +} + +function SourceBadge({ source }: { source: "default" | "plan" | "override" }) { + const variants: Record = { + default: { + label: "Default", + className: "bg-charcoal-700 text-text-dimmed", + }, + plan: { + label: "Plan", + className: "bg-indigo-500/20 text-indigo-400", + }, + override: { + label: "Override", + className: "bg-amber-500/20 text-amber-400", + }, + }; + + const variant = variants[source]; + + return ( + + {variant.label} + + ); +} + diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index a2756f7e5b..639f2f7294 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -507,6 +507,14 @@ export function concurrencyPath( return `${v3EnvironmentPath(organization, project, environment)}/concurrency`; } +export function limitsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/limits`; +} + export function regionsPath( organization: OrgForPath, project: ProjectForPath, From e250cd02fd8f43b2d391afeac9ee636214270495 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 Jan 2026 14:53:43 +0000 Subject: [PATCH 02/37] Improvements --- .../presenters/v3/LimitsPresenter.server.ts | 347 +++++++++++----- .../route.tsx | 389 ++++++++++++------ .../tsql/src/query/printer.test.ts | 147 +++++++ internal-packages/tsql/src/query/printer.ts | 4 +- 4 files changed, 661 insertions(+), 226 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index b06b88573b..873c36c7bc 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -1,14 +1,27 @@ -import { type RuntimeEnvironmentType } from "@trigger.dev/database"; +import { createHash } from "node:crypto"; import { env } from "~/env.server"; -import { getCurrentPlan, getDefaultEnvironmentLimitFromPlan } from "~/services/platform.v3.server"; +import { createRedisClient } from "~/redis.server"; +import { getCurrentPlan } from "~/services/platform.v3.server"; import { RateLimiterConfig, type RateLimitTokenBucketConfig, } from "~/services/authorizationRateLimitMiddleware.server"; import type { Duration } from "~/services/rateLimiter.server"; import { BasePresenter } from "./basePresenter.server"; -import { sortEnvironments } from "~/utils/environmentSort"; -import { engine } from "~/v3/runEngine.server"; +import { singleton } from "~/utils/singleton"; +import { logger } from "~/services/logger.server"; + +// Create a singleton Redis client for rate limit queries +const rateLimitRedis = singleton("rateLimitQueryRedis", () => + createRedisClient("trigger:rateLimitQuery", { + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }) +); // Types for rate limit display export type RateLimitInfo = { @@ -16,17 +29,7 @@ export type RateLimitInfo = { description: string; config: RateLimiterConfig; source: "default" | "plan" | "override"; -}; - -// Types for concurrency limit display -export type ConcurrencyLimitInfo = { - environmentId: string; - environmentType: RuntimeEnvironmentType; - branchName: string | null; - limit: number; - currentUsage: number; - planLimit: number; - source: "default" | "plan" | "override"; + currentTokens: number | null; }; // Types for quota display @@ -38,15 +41,27 @@ export type QuotaInfo = { source: "default" | "plan" | "override"; }; +// Types for feature flags +export type FeatureInfo = { + name: string; + description: string; + enabled: boolean; + value?: string | number; +}; + export type LimitsResult = { rateLimits: { api: RateLimitInfo; batch: RateLimitInfo; }; - concurrencyLimits: ConcurrencyLimitInfo[]; quotas: { projects: QuotaInfo; schedules: QuotaInfo | null; + teamMembers: QuotaInfo | null; + alerts: QuotaInfo | null; + branches: QuotaInfo | null; + logRetentionDays: QuotaInfo | null; + realtimeConnections: QuotaInfo | null; devQueueSize: QuotaInfo; deployedQueueSize: QuotaInfo; }; @@ -54,7 +69,13 @@ export type LimitsResult = { limit: number; source: "default" | "override"; }; + features: { + hasStagingEnvironment: FeatureInfo; + support: FeatureInfo; + includedUsage: FeatureInfo; + }; planName: string | null; + organizationId: string; }; export class LimitsPresenter extends BasePresenter { @@ -62,10 +83,12 @@ export class LimitsPresenter extends BasePresenter { userId, projectId, organizationId, + environmentApiKey, }: { userId: string; projectId: string; organizationId: string; + environmentApiKey: string; }): Promise { // Get organization with all limit-related fields const organization = await this._replica.organization.findUniqueOrThrow({ @@ -84,6 +107,7 @@ export class LimitsPresenter extends BasePresenter { projects: { where: { deletedAt: null }, }, + members: true, }, }, }, @@ -91,78 +115,7 @@ export class LimitsPresenter extends BasePresenter { // Get current plan from billing service const currentPlan = await getCurrentPlan(organizationId); - - // Get environments for this project - const environments = await this._replica.runtimeEnvironment.findMany({ - select: { - id: true, - type: true, - branchName: true, - maximumConcurrencyLimit: true, - orgMember: { - select: { - userId: true, - }, - }, - }, - where: { - projectId, - archivedAt: null, - }, - }); - - // Get current concurrency for each environment - const concurrencyLimits: ConcurrencyLimitInfo[] = []; - for (const environment of environments) { - // Skip dev environments that belong to other users - if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) { - continue; - } - - const planLimit = currentPlan - ? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan) ?? - env.DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT - : env.DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT; - - // Get current concurrency from Redis - let currentUsage = 0; - try { - currentUsage = await engine.runQueue.currentConcurrencyOfEnvironment({ - id: environment.id, - type: environment.type, - organizationId, - projectId, - }); - } catch (e) { - // Redis might not be available, default to 0 - } - - // Determine source - let source: "default" | "plan" | "override" = "default"; - if (environment.maximumConcurrencyLimit !== planLimit) { - source = "override"; - } else if (currentPlan?.v3Subscription?.plan) { - source = "plan"; - } - - concurrencyLimits.push({ - environmentId: environment.id, - environmentType: environment.type, - branchName: environment.branchName, - limit: environment.maximumConcurrencyLimit, - currentUsage, - planLimit, - source, - }); - } - - // Sort environments - const sortedConcurrencyLimits = sortEnvironments(concurrencyLimits, [ - "PRODUCTION", - "STAGING", - "PREVIEW", - "DEVELOPMENT", - ]); + const limits = currentPlan?.v3Subscription?.plan?.limits; // Resolve API rate limit config const apiRateLimitConfig = resolveApiRateLimitConfig(organization.apiRateLimiterConfig); @@ -193,8 +146,50 @@ export class LimitsPresenter extends BasePresenter { }, }); - // Get plan-level schedule limit - const schedulesLimit = currentPlan?.v3Subscription?.plan?.limits?.schedules?.number ?? null; + // Get alert channel count for this org + const alertChannelCount = await this._replica.projectAlertChannel.count({ + where: { + project: { + organizationId, + }, + }, + }); + + // Get active branches count for this org + const activeBranchCount = await this._replica.runtimeEnvironment.count({ + where: { + project: { + organizationId, + }, + branchName: { + not: null, + }, + archivedAt: null, + }, + }); + + // Get current rate limit tokens for this environment's API key + const apiRateLimitTokens = await getRateLimitRemainingTokens( + "api", + environmentApiKey, + apiRateLimitConfig + ); + const batchRateLimitTokens = await getRateLimitRemainingTokens( + "batch", + environmentApiKey, + batchRateLimitConfig + ); + + // Get plan-level limits + const schedulesLimit = limits?.schedules?.number ?? null; + const teamMembersLimit = limits?.teamMembers?.number ?? null; + const alertsLimit = limits?.alerts?.number ?? null; + const branchesLimit = limits?.branches?.number ?? null; + const logRetentionDaysLimit = limits?.logRetentionDays?.number ?? null; + const realtimeConnectionsLimit = limits?.realtimeConcurrentConnections?.number ?? null; + const includedUsage = limits?.includedUsage ?? null; + const hasStagingEnvironment = limits?.hasStagingEnvironment ?? false; + const supportLevel = limits?.support ?? "community"; return { rateLimits: { @@ -203,15 +198,16 @@ export class LimitsPresenter extends BasePresenter { description: "Rate limit for API requests (trigger, batch, etc.)", config: apiRateLimitConfig, source: apiRateLimitSource, + currentTokens: apiRateLimitTokens, }, batch: { name: "Batch Rate Limit", description: "Rate limit for batch trigger operations", config: batchRateLimitConfig, source: batchRateLimitSource, + currentTokens: batchRateLimitTokens, }, }, - concurrencyLimits: sortedConcurrencyLimits, quotas: { projects: { name: "Projects", @@ -230,15 +226,65 @@ export class LimitsPresenter extends BasePresenter { source: "plan", } : null, + teamMembers: + teamMembersLimit !== null + ? { + name: "Team members", + description: "Maximum number of team members in this organization", + limit: teamMembersLimit, + currentUsage: organization._count.members, + source: "plan", + } + : null, + alerts: + alertsLimit !== null + ? { + name: "Alert channels", + description: "Maximum number of alert channels across all projects", + limit: alertsLimit, + currentUsage: alertChannelCount, + source: "plan", + } + : null, + branches: + branchesLimit !== null + ? { + name: "Preview branches", + description: "Maximum number of active preview branches", + limit: branchesLimit, + currentUsage: activeBranchCount, + source: "plan", + } + : null, + logRetentionDays: + logRetentionDaysLimit !== null + ? { + name: "Log retention", + description: "Number of days logs are retained", + limit: logRetentionDaysLimit, + currentUsage: 0, // Not applicable - this is a duration, not a count + source: "plan", + } + : null, + realtimeConnections: + realtimeConnectionsLimit !== null + ? { + name: "Realtime connections", + description: "Maximum concurrent realtime connections", + limit: realtimeConnectionsLimit, + currentUsage: 0, // Would need to query realtime service for this + source: "plan", + } + : null, devQueueSize: { - name: "Dev Queue Size", + name: "Dev queue size", description: "Maximum pending runs in development environments", limit: organization.maximumDevQueueSize ?? null, currentUsage: 0, // Would need to query Redis for this source: organization.maximumDevQueueSize ? "override" : "default", }, deployedQueueSize: { - name: "Deployed Queue Size", + name: "Deployed queue size", description: "Maximum pending runs in deployed environments", limit: organization.maximumDeployedQueueSize ?? null, currentUsage: 0, // Would need to query Redis for this @@ -249,7 +295,27 @@ export class LimitsPresenter extends BasePresenter { limit: batchConcurrencyConfig.processingConcurrency, source: batchConcurrencySource, }, + features: { + hasStagingEnvironment: { + name: "Staging environment", + description: "Access to staging environment for testing before production", + enabled: hasStagingEnvironment, + }, + support: { + name: "Support level", + description: "Type of support available for your plan", + enabled: true, + value: supportLevel === "slack" ? "Slack" : "Community", + }, + includedUsage: { + name: "Included compute", + description: "Monthly included compute credits", + enabled: includedUsage !== null && includedUsage > 0, + value: includedUsage ?? 0, + }, + }, planName: currentPlan?.v3Subscription?.plan?.title ?? null, + organizationId, }; } } @@ -314,3 +380,98 @@ function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): { return defaultConfig; } + +/** + * Query Redis for the current remaining tokens for a rate limiter. + * The @upstash/ratelimit library stores token bucket state in Redis. + * Key format: ratelimit:{prefix}:{hashedIdentifier} + * + * For token bucket, the value is stored as: "tokens:lastRefillTime" + */ +async function getRateLimitRemainingTokens( + keyPrefix: string, + apiKey: string, + config: RateLimiterConfig +): Promise { + try { + // Hash the authorization header the same way the rate limiter does + const authorizationValue = `Bearer ${apiKey}`; + const hash = createHash("sha256"); + hash.update(authorizationValue); + const hashedKey = hash.digest("hex"); + + const redis = rateLimitRedis; + const redisKey = `ratelimit:${keyPrefix}:${hashedKey}`; + + // Get the stored value from Redis + const value = await redis.get(redisKey); + + if (!value) { + // No rate limit data yet - return max tokens (bucket is full) + if (config.type === "tokenBucket") { + return config.maxTokens; + } else if (config.type === "fixedWindow" || config.type === "slidingWindow") { + return config.tokens; + } + return null; + } + + // For token bucket, the @upstash/ratelimit library stores: "tokens:timestamp" + // Parse the value to get remaining tokens + if (typeof value === "string") { + const parts = value.split(":"); + if (parts.length >= 1) { + const tokens = parseInt(parts[0], 10); + if (!isNaN(tokens)) { + // For token bucket, we need to calculate current tokens based on refill + if (config.type === "tokenBucket" && parts.length >= 2) { + const lastRefillTime = parseInt(parts[1], 10); + if (!isNaN(lastRefillTime)) { + const now = Date.now(); + const elapsed = now - lastRefillTime; + const intervalMs = durationToMs(config.interval); + const tokensToAdd = Math.floor(elapsed / intervalMs) * config.refillRate; + const currentTokens = Math.min(tokens + tokensToAdd, config.maxTokens); + return Math.max(0, currentTokens); + } + } + return Math.max(0, tokens); + } + } + } + + return null; + } catch (error) { + logger.warn("Failed to get rate limit remaining tokens", { + keyPrefix, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Convert a duration string (e.g., "1s", "10s", "1m") to milliseconds + */ +function durationToMs(duration: Duration): number { + const match = duration.match(/^(\d+)(ms|s|m|h|d)$/); + if (!match) return 1000; // default to 1 second + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "ms": + return value; + case "s": + return value * 1000; + case "m": + return value * 60 * 1000; + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + default: + return 1000; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 913a31a963..a7981b0e48 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -1,18 +1,15 @@ -import { InformationCircleIcon } from "@heroicons/react/20/solid"; +import { AdjustmentsHorizontalIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { tryCatch } from "@trigger.dev/core"; -import { motion, useMotionValue, useTransform } from "framer-motion"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; -import { - MainHorizontallyCenteredContainer, - PageBody, - PageContainer, -} from "~/components/layout/AppLayout"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; -import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Callout } from "~/components/primitives/Callout"; +import { Header2 } 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"; @@ -26,18 +23,22 @@ import { } from "~/components/primitives/Table"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { LimitsPresenter, + type FeatureInfo, type LimitsResult, - type RateLimitInfo, - type ConcurrencyLimitInfo, type QuotaInfo, + type RateLimitInfo, } from "~/presenters/v3/LimitsPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { formatNumber } from "~/utils/numberFormatter"; +import { concurrencyPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -49,7 +50,7 @@ export const meta: MetaFunction = () => { export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam } = EnvironmentParamSchema.parse(params); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { @@ -59,12 +60,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); } + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + const presenter = new LimitsPresenter(); const [error, result] = await tryCatch( presenter.call({ userId, projectId: project.id, organizationId: project.organizationId, + environmentApiKey: environment.apiKey, }) ); @@ -79,8 +89,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export default function Page() { const data = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); - // Auto-revalidate every 5 seconds to get fresh concurrency data + // Auto-revalidate every 5 seconds to get fresh rate limit data useAutoRevalidate({ interval: 5000 }); return ( @@ -94,12 +107,16 @@ export default function Page() { Plan {data.planName ?? "No plan"} + + Organization ID + {data.organizationId} + - +
{/* Plan info */} {data.planName && ( @@ -108,114 +125,72 @@ export default function Page() { Current plan: {data.planName} - - (Limits refresh automatically every 5 seconds) -
)} - {/* Concurrency Limits Section */} - + {/* Concurrency Link Section */} + } + to={concurrencyPath(organization, project, environment)} + > + + Concurrency limits are managed on a dedicated + page where you can view and adjust limits for each environment. + + {/* Rate Limits Section */} - + {/* Quotas Section */} + + {/* Features Section */} +
-
+
); } -function ConcurrencyLimitsSection({ limits }: { limits: ConcurrencyLimitInfo[] }) { +function RateLimitsSection({ + rateLimits, + environmentType, +}: { + rateLimits: LimitsResult["rateLimits"]; + environmentType: RuntimeEnvironmentType; +}) { return (
- Concurrency Limits + Rate Limits +
+
+ + Rate limits control how many API requests can be made within a time window. + +
+ + + Showing current tokens for this environment's API key. Rate limits are tracked per API + key. + +
- - Concurrency limits determine how many runs can execute at the same time in each environment. - The current usage updates in real-time. - - Environment - - - Limit - - - + Rate Limit + Type + Configuration Current - + - Usage - Source - - - - {limits.map((limit) => ( - - ))} - -
-
- ); -} - -function ConcurrencyLimitRow({ limit }: { limit: ConcurrencyLimitInfo }) { - const percentage = limit.limit > 0 ? limit.currentUsage / limit.limit : 0; - const cappedPercentage = Math.min(percentage, 1); - - return ( - - - - - - {formatNumber(limit.limit)} - - - {formatNumber(limit.currentUsage)} - - - - - - - - - ); -} - -function RateLimitsSection({ rateLimits }: { rateLimits: LimitsResult["rateLimits"] }) { - return ( -
-
- Rate Limits -
- - Rate limits control how many API requests can be made within a time window. These use a - token bucket algorithm that refills tokens over time. - - - - - Rate Limit - Type - Configuration Source @@ -229,6 +204,10 @@ function RateLimitsSection({ rateLimits }: { rateLimits: LimitsResult["rateLimit } function RateLimitRow({ info }: { info: RateLimitInfo }) { + const maxTokens = info.config.type === "tokenBucket" ? info.config.maxTokens : info.config.tokens; + const percentage = + info.currentTokens !== null && maxTokens > 0 ? info.currentTokens / maxTokens : null; + return ( @@ -238,11 +217,30 @@ function RateLimitRow({ info }: { info: RateLimitInfo }) { - {info.config.type} + + + {info.currentTokens !== null ? ( +
+ + {formatNumber(info.currentTokens)} + + + of {formatNumber(maxTokens)} + +
+ ) : ( + + )} +
@@ -250,6 +248,46 @@ function RateLimitRow({ info }: { info: RateLimitInfo }) { ); } +function RateLimitTypeBadge({ config }: { config: RateLimitInfo["config"] }) { + switch (config.type) { + case "tokenBucket": { + const tooltip = `Requests consume tokens from a bucket. The bucket refills at ${formatNumber( + config.refillRate + )} tokens per ${config.interval} up to a maximum of ${formatNumber( + config.maxTokens + )} tokens. When the bucket is empty, requests are rate limited until tokens refill.`; + return ( + + Token bucket + + + ); + } + case "fixedWindow": { + const tooltip = `Allows ${formatNumber(config.tokens)} requests per ${ + config.window + } time window. The window resets at fixed intervals.`; + return ( + + Fixed window + + + ); + } + case "slidingWindow": { + const tooltip = `Allows ${formatNumber(config.tokens)} requests per ${ + config.window + } rolling time window. The limit is continuously evaluated.`; + return ( + + Sliding window + + + ); + } + } +} + function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) { if (config.type === "tokenBucket") { return ( @@ -293,6 +331,24 @@ function QuotasSection({ quotas: LimitsResult["quotas"]; batchConcurrency: LimitsResult["batchConcurrency"]; }) { + // Collect all quotas that should be shown + const quotaRows: QuotaInfo[] = []; + + // Always show projects + quotaRows.push(quotas.projects); + + // Add plan-based quotas if they exist + if (quotas.teamMembers) quotaRows.push(quotas.teamMembers); + if (quotas.schedules) quotaRows.push(quotas.schedules); + if (quotas.alerts) quotaRows.push(quotas.alerts); + if (quotas.branches) quotaRows.push(quotas.branches); + if (quotas.realtimeConnections) quotaRows.push(quotas.realtimeConnections); + if (quotas.logRetentionDays) quotaRows.push(quotas.logRetentionDays); + + // Add queue size quotas if set + if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize); + if (quotas.deployedQueueSize.limit !== null) quotaRows.push(quotas.deployedQueueSize); + return (
@@ -311,18 +367,19 @@ function QuotasSection({ - - {quotas.schedules && } + {quotaRows.map((quota) => ( + + ))}
- Batch Processing Concurrency + Batch processing concurrency Concurrent batch items being processed
- + {formatNumber(batchConcurrency.limit)} @@ -332,10 +389,6 @@ function QuotasSection({
- {quotas.devQueueSize.limit !== null && } - {quotas.deployedQueueSize.limit !== null && ( - - )}
@@ -343,8 +396,10 @@ function QuotasSection({ } function QuotaRow({ quota }: { quota: QuotaInfo }) { - const percentage = quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : 0; - const isAtLimit = percentage >= 1; + // For log retention, we don't show current usage as it's a duration, not a count + const isRetentionQuota = quota.name === "Log retention"; + const percentage = + !isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null; return ( @@ -354,14 +409,21 @@ function QuotaRow({ quota }: { quota: QuotaInfo }) { {quota.description} - - {quota.limit !== null ? formatNumber(quota.limit) : "Unlimited"} + + {quota.limit !== null + ? isRetentionQuota + ? `${formatNumber(quota.limit)} days` + : formatNumber(quota.limit) + : "Unlimited"} - {formatNumber(quota.currentUsage)} + {isRetentionQuota ? "–" : formatNumber(quota.currentUsage)} @@ -370,32 +432,96 @@ function QuotaRow({ quota }: { quota: QuotaInfo }) { ); } -function UsageBar({ percentage }: { percentage: number }) { - const widthProgress = useMotionValue(percentage * 100); - const color = useTransform( - widthProgress, - [0, 74, 75, 95, 100], - ["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"] - ); - +function FeaturesSection({ features }: { features: LimitsResult["features"] }) { return ( -
-
- +
+
+ Plan Features
- - {Math.round(percentage * 100)}% - + Features and capabilities included with your plan. + + + + Feature + Status + + + + + + + +
); } +function FeatureRow({ feature }: { feature: FeatureInfo }) { + const displayValue = () => { + if (feature.name === "Included compute" && typeof feature.value === "number") { + if (!feature.enabled || feature.value === 0) { + return None; + } + return ( + ${formatNumber(feature.value / 100)} + ); + } + + if (feature.value !== undefined) { + return {feature.value}; + } + + return feature.enabled ? ( + + + Enabled + + ) : ( + + + Not available + + ); + }; + + return ( + + +
+ {feature.name} + {feature.description} +
+
+ {displayValue()} +
+ ); +} + +/** + * Returns the appropriate color class based on usage percentage. + * @param percentage - The usage percentage (0-1 scale) + * @param mode - "usage" means higher is worse (quotas), "remaining" means lower is worse (rate limits) + * @returns Tailwind color class + */ +function getUsageColorClass( + percentage: number | null, + mode: "usage" | "remaining" = "usage" +): string { + if (percentage === null) return "text-text-dimmed"; + + if (mode === "remaining") { + // For remaining tokens: 0 = bad (red), <=10% = warning (orange) + if (percentage <= 0) return "text-error"; + if (percentage <= 0.1) return "text-warning"; + return "text-text-bright"; + } else { + // For usage: 100% = bad (red), >=90% = warning (orange) + if (percentage >= 1) return "text-error"; + if (percentage >= 0.9) return "text-warning"; + return "text-text-bright"; + } +} + function SourceBadge({ source }: { source: "default" | "plan" | "override" }) { const variants: Record = { default: { @@ -425,4 +551,3 @@ function SourceBadge({ source }: { source: "default" | "plan" | "override" }) { ); } - diff --git a/internal-packages/tsql/src/query/printer.test.ts b/internal-packages/tsql/src/query/printer.test.ts index 6b82c08b07..dcbb79b2d8 100644 --- a/internal-packages/tsql/src/query/printer.test.ts +++ b/internal-packages/tsql/src/query/printer.test.ts @@ -2612,3 +2612,150 @@ describe("Internal-only column blocking", () => { }); }); }); + +describe("Required Filters", () => { + /** + * Tests for tables with requiredFilters, which inject internal ClickHouse + * column conditions (like engine = 'V2') that aren't exposed in the schema. + */ + + const schemaWithRequiredFilters: TableSchema = { + name: "runs", + clickhouseName: "trigger_dev.task_runs_v2", + description: "Task runs table with required filters", + tenantColumns: { + organizationId: "organization_id", + projectId: "project_id", + environmentId: "environment_id", + }, + requiredFilters: [{ column: "engine", value: "V2" }], + columns: { + run_id: { + name: "run_id", + clickhouseName: "friendly_id", + ...column("String", { description: "Run ID", coreColumn: true }), + }, + status: { + name: "status", + ...column("String", { description: "Status" }), + }, + triggered_at: { + name: "triggered_at", + clickhouseName: "created_at", + ...column("DateTime64", { description: "When the run was triggered", coreColumn: true }), + }, + total_cost: { + name: "total_cost", + ...column("Float64", { description: "Total cost" }), + expression: "(cost_in_cents + base_cost_in_cents) / 100.0", + }, + }, + }; + + function createRequiredFiltersContext(): PrinterContext { + const schemaRegistry = createSchemaRegistry([schemaWithRequiredFilters]); + return createPrinterContext({ + organizationId: "org_test123", + projectId: "proj_test456", + environmentId: "env_test789", + schema: schemaRegistry, + }); + } + + function printQueryWithFilters(query: string): PrintResult { + const ctx = createRequiredFiltersContext(); + const ast = parseTSQLSelect(query); + const printer = new ClickHousePrinter(ctx); + return printer.print(ast); + } + + it("should NOT throw for internal engine column from requiredFilters", () => { + // This query should work even though 'engine' is not in the schema + // because it's automatically injected by requiredFilters + const { sql, params } = printQueryWithFilters("SELECT run_id, status FROM runs LIMIT 10"); + + // The engine filter should be in the WHERE clause + expect(sql).toContain("engine"); + // The V2 value is parameterized, so check the params + expect(Object.values(params)).toContain("V2"); + }); + + it("should allow TSQL column names that map to different ClickHouse names", () => { + // User writes 'triggered_at' but it maps to 'created_at' in ClickHouse + const { sql } = printQueryWithFilters(` + SELECT run_id, status, triggered_at + FROM runs + WHERE triggered_at > now() - INTERVAL 14 DAY + ORDER BY triggered_at DESC + LIMIT 100 + `); + + // The ClickHouse SQL should use 'created_at' instead of 'triggered_at' + expect(sql).toContain("created_at"); + // The result should still have the alias for the user-friendly name + expect(sql).toContain("AS triggered_at"); + }); + + it("should allow filtering by mapped column name", () => { + const { sql } = printQueryWithFilters(` + SELECT run_id FROM runs WHERE triggered_at > '2024-01-01' LIMIT 10 + `); + + // Should use the ClickHouse column name in the WHERE clause + expect(sql).toContain("created_at"); + }); + + it("should allow ORDER BY on mapped column name", () => { + const { sql } = printQueryWithFilters(` + SELECT run_id FROM runs ORDER BY triggered_at DESC LIMIT 10 + `); + + // ORDER BY should use the ClickHouse column name + expect(sql).toContain("ORDER BY"); + expect(sql).toContain("created_at"); + }); + + it("should handle virtual columns with expressions", () => { + const { sql } = printQueryWithFilters(` + SELECT run_id, total_cost FROM runs ORDER BY total_cost DESC LIMIT 10 + `); + + // Virtual column should be expanded to its expression with an alias + expect(sql).toContain("cost_in_cents"); + expect(sql).toContain("base_cost_in_cents"); + expect(sql).toContain("AS total_cost"); + }); + + it("should combine tenant guards with required filters", () => { + const { sql, params } = printQueryWithFilters("SELECT run_id FROM runs LIMIT 10"); + + // Should have all tenant columns AND the engine filter + expect(sql).toContain("organization_id"); + expect(sql).toContain("project_id"); + expect(sql).toContain("environment_id"); + expect(sql).toContain("engine"); + // The V2 value is parameterized, so check the params + expect(Object.values(params)).toContain("V2"); + }); + + it("should allow complex queries with mapped columns", () => { + // This query is similar to what a user might write + const { sql } = printQueryWithFilters(` + SELECT + run_id, + status, + total_cost, + triggered_at + FROM runs + WHERE triggered_at > now() - INTERVAL 14 DAY + ORDER BY total_cost DESC + LIMIT 100 + `); + + // All should work without errors + expect(sql).toContain("friendly_id"); // run_id maps to friendly_id + expect(sql).toContain("status"); + expect(sql).toContain("created_at"); // triggered_at maps to created_at + expect(sql).toContain("cost_in_cents"); // total_cost is a virtual column + }); +}); diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index dcdc82d69b..75fcd56628 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -2280,7 +2280,9 @@ export class ClickHousePrinter { if (this.inProjectionContext && this.internalOnlyColumns.has(columnName)) { const availableColumns = this.getAvailableColumnNames(); throw new QueryError( - `Column "${columnName}" is not available for querying. Available columns: ${availableColumns.join(", ")}` + `Column "${columnName}" is not available for querying. Available columns: ${availableColumns.join( + ", " + )}` ); } From 0ea6b6d10680d86e14e00d8bdecdbf8547d3e6ad Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 16:20:41 +0000 Subject: [PATCH 03/37] Layout improvements --- .../route.tsx | 98 ++++++++++++++----- 1 file changed, 73 insertions(+), 25 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index a7981b0e48..d28262d3a8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -1,4 +1,4 @@ -import { AdjustmentsHorizontalIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; @@ -7,8 +7,9 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Feedback } from "~/components/Feedback"; import { Badge } from "~/components/primitives/Badge"; -import { Callout } from "~/components/primitives/Callout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -38,7 +39,11 @@ import { import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { formatNumber } from "~/utils/numberFormatter"; -import { concurrencyPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { + concurrencyPath, + EnvironmentParamSchema, + organizationBillingPath, +} from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -116,29 +121,20 @@ export default function Page() { -
+
- {/* Plan info */} - {data.planName && ( -
- - Current plan: - - {data.planName} -
- )} - - {/* Concurrency Link Section */} - } - to={concurrencyPath(organization, project, environment)} - > - - Concurrency limits are managed on a dedicated - page where you can view and adjust limits for each environment. - - + {/* Current Plan Section */} + {/* {data.planName && ( */} + + {/* )} */} + + {/* Concurrency Section */} + {/* Rate Limits Section */} @@ -155,6 +151,58 @@ export default function Page() { ); } +function CurrentPlanSection({ planName, billingPath }: { planName: string; billingPath: string }) { + const isPro = planName === "Pro"; + + return ( +
+ Current plan + + + + {planName} + + {isPro ? ( + Request Enterprise} + defaultValue="help" + /> + ) : ( + + View plans to upgrade + + )} + + + +
+
+ ); +} + +function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) { + return ( +
+ + Concurrency limits + + + + + + Concurrency + + + Manage concurrency + + + + +
+
+ ); +} + function RateLimitsSection({ rateLimits, environmentType, From da2eeff7eb3153d9a9f6415a057c38325c602e6b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 16:25:23 +0000 Subject: [PATCH 04/37] Adds tooltips to tidy up the information --- .../route.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index d28262d3a8..576d08ee3a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -399,12 +399,10 @@ function QuotasSection({ return (
-
- Quotas -
- - Quotas define the maximum resources available to your organization. - + + Quotas + + @@ -483,10 +481,7 @@ function QuotaRow({ quota }: { quota: QuotaInfo }) { function FeaturesSection({ features }: { features: LimitsResult["features"] }) { return (
-
- Plan Features -
- Features and capabilities included with your plan. + Plan Features
From 8a116ef50e2e7dfc519fefc6d2da57dc2de25926 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 17:14:45 +0000 Subject: [PATCH 05/37] More table layout improvements --- .../route.tsx | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 576d08ee3a..c22be9d3cf 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -160,7 +160,7 @@ function CurrentPlanSection({ planName, billingPath }: { planName: string; billi
- {planName} + {planName} {isPro ? ( Concurrency limits - +
- Concurrency + Concurrency Manage concurrency @@ -212,13 +215,14 @@ function RateLimitsSection({ }) { return (
-
- Rate Limits -
+ + Rate Limits + +
- - Rate limits control how many API requests can be made within a time window. -
@@ -235,8 +239,11 @@ function RateLimitsSection({ Configuration - Current - + Available + Source @@ -307,7 +314,7 @@ function RateLimitTypeBadge({ config }: { config: RateLimitInfo["config"] }) { return ( Token bucket - + ); } @@ -318,7 +325,7 @@ function RateLimitTypeBadge({ config }: { config: RateLimitInfo["config"] }) { return ( Fixed window - + ); } @@ -329,7 +336,7 @@ function RateLimitTypeBadge({ config }: { config: RateLimitInfo["config"] }) { return ( Sliding window - + ); } @@ -401,7 +408,10 @@ function QuotasSection({
Quotas - +
@@ -417,13 +427,12 @@ function QuotasSection({ ))} - -
- Batch processing concurrency - - Concurrent batch items being processed - -
+ + Batch processing concurrency + {formatNumber(batchConcurrency.limit)} @@ -449,11 +458,9 @@ function QuotaRow({ quota }: { quota: QuotaInfo }) { return ( - -
- {quota.name} - {quota.description} -
+ + {quota.name} + {quota.limit !== null @@ -520,10 +527,7 @@ function FeatureRow({ feature }: { feature: FeatureInfo }) { Enabled ) : ( - - - Not available - + Not available ); }; From 9504e702eb69e42191884d2cc11d3bfca9dd3e7b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 17:19:40 +0000 Subject: [PATCH 06/37] Improves features section layout --- .../route.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index c22be9d3cf..a7d673f408 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -523,7 +523,7 @@ function FeatureRow({ feature }: { feature: FeatureInfo }) { return feature.enabled ? ( - + Enabled ) : ( @@ -533,11 +533,9 @@ function FeatureRow({ feature }: { feature: FeatureInfo }) { return ( - -
- {feature.name} - {feature.description} -
+ + {feature.name} + {displayValue()}
From 31d9aaa43e10dd5742380a62239b66f71292a44f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 18:15:55 +0000 Subject: [PATCH 07/37] Adds Upgrade column --- .../route.tsx | 292 +++++++++++++----- 1 file changed, 221 insertions(+), 71 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index a7d673f408..f84199639d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -1,18 +1,16 @@ import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { tryCatch } from "@trigger.dev/core"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Feedback } from "~/components/Feedback"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header2 } 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"; import { Table, @@ -124,12 +122,12 @@ export default function Page() {
{/* Current Plan Section */} - {/* {data.planName && ( */} - - {/* )} */} + {data.planName && ( + + )} {/* Concurrency Section */} {/* Rate Limits Section */} - + {/* Quotas Section */} - + {/* Features Section */} - +
@@ -208,34 +220,69 @@ function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) { function RateLimitsSection({ rateLimits, - environmentType, + organization, + project, + environment, }: { rateLimits: LimitsResult["rateLimits"]; - environmentType: RuntimeEnvironmentType; + organization: ReturnType; + project: ReturnType; + environment: ReturnType; }) { return (
- - Rate Limits - + + Rate Limits + + + - -
-
- - - Showing current tokens for this environment's API key. Rate limits are tracked per API - key. - -
Rate Limit - Type + + + Type + +
+ Token bucket + + Requests consume tokens from a bucket that refills over time. When empty, + requests are rate limited. + +
+
+ Fixed window + + Allows a set number of requests per time window. The window resets at + fixed intervals. + +
+
+ Sliding window + + Allows a set number of requests per rolling time window. The limit is + continuously evaluated. + +
+ + } + disableHoverableContent + /> +
+
Configuration @@ -247,6 +294,7 @@ function RateLimitsSection({ Source + Upgrade
@@ -266,13 +314,15 @@ function RateLimitRow({ info }: { info: RateLimitInfo }) { return ( -
- {info.name} - {info.description} -
+ + {info.name} + +
- - + +
+ +
@@ -299,47 +349,38 @@ function RateLimitRow({ info }: { info: RateLimitInfo }) { + +
+ Contact us} + defaultValue="help" + /> +
+
); } function RateLimitTypeBadge({ config }: { config: RateLimitInfo["config"] }) { switch (config.type) { - case "tokenBucket": { - const tooltip = `Requests consume tokens from a bucket. The bucket refills at ${formatNumber( - config.refillRate - )} tokens per ${config.interval} up to a maximum of ${formatNumber( - config.maxTokens - )} tokens. When the bucket is empty, requests are rate limited until tokens refill.`; + case "tokenBucket": return ( - - Token bucket - - + + Token bucket + ); - } - case "fixedWindow": { - const tooltip = `Allows ${formatNumber(config.tokens)} requests per ${ - config.window - } time window. The window resets at fixed intervals.`; + case "fixedWindow": return ( - - Fixed window - - + + Fixed window + ); - } - case "slidingWindow": { - const tooltip = `Allows ${formatNumber(config.tokens)} requests per ${ - config.window - } rolling time window. The limit is continuously evaluated.`; + case "slidingWindow": return ( - - Sliding window - - + + Sliding window + ); - } } } @@ -382,10 +423,15 @@ function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) function QuotasSection({ quotas, batchConcurrency, + planName, + billingPath, }: { quotas: LimitsResult["quotas"]; batchConcurrency: LimitsResult["batchConcurrency"]; + planName: string | null; + billingPath: string; }) { + const isPro = planName === "Pro"; // Collect all quotas that should be shown const quotaRows: QuotaInfo[] = []; @@ -420,11 +466,18 @@ function QuotasSection({ Limit Current Source + Upgrade {quotaRows.map((quota) => ( - + ))} @@ -443,6 +496,20 @@ function QuotasSection({ + +
+ {isPro ? ( + Contact us} + defaultValue="help" + /> + ) : ( + + View plans + + )} +
+
@@ -450,7 +517,17 @@ function QuotasSection({ ); } -function QuotaRow({ quota }: { quota: QuotaInfo }) { +function QuotaRow({ + quota, + showUpgrade, + isPro, + billingPath, +}: { + quota: QuotaInfo; + showUpgrade: boolean; + isPro: boolean; + billingPath: string; +}) { // For log retention, we don't show current usage as it's a duration, not a count const isRetentionQuota = quota.name === "Log retention"; const percentage = @@ -481,11 +558,38 @@ function QuotaRow({ quota }: { quota: QuotaInfo }) { + + {showUpgrade ? ( +
+ {isPro ? ( + Contact us} + defaultValue="help" + /> + ) : ( + + View plans + + )} +
+ ) : null} +
); } -function FeaturesSection({ features }: { features: LimitsResult["features"] }) { +function FeaturesSection({ + features, + planName, + billingPath, +}: { + features: LimitsResult["features"]; + planName: string | null; + billingPath: string; +}) { + const isPro = planName === "Pro"; + const isFree = planName === "Free" || planName === null; + return (
Plan Features @@ -494,19 +598,40 @@ function FeaturesSection({ features }: { features: LimitsResult["features"] }) { Feature Status + Upgrade - - - + + +
); } -function FeatureRow({ feature }: { feature: FeatureInfo }) { +function FeatureRow({ + feature, + upgradeType, + billingPath, +}: { + feature: FeatureInfo; + upgradeType: "view-plans" | "contact-us" | "none"; + billingPath: string; +}) { const displayValue = () => { if (feature.name === "Included compute" && typeof feature.value === "number") { if (!feature.enabled || feature.value === 0) { @@ -531,6 +656,30 @@ function FeatureRow({ feature }: { feature: FeatureInfo }) { ); }; + const renderUpgrade = () => { + switch (upgradeType) { + case "view-plans": + return ( +
+ + View plans + +
+ ); + case "contact-us": + return ( +
+ Contact us} + defaultValue="help" + /> +
+ ); + case "none": + return null; + } + }; + return ( @@ -538,6 +687,7 @@ function FeatureRow({ feature }: { feature: FeatureInfo }) { {displayValue()} + {renderUpgrade()} ); } From 54c8678b700dc2b05855cdf8bc4fd58954ca45c3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 20:50:35 +0000 Subject: [PATCH 08/37] Add button icon and update icon colors --- apps/webapp/app/components/navigation/SideMenu.tsx | 6 +++--- .../route.tsx | 8 +++++++- apps/webapp/tailwind.config.js | 6 ++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index c6a4bf209e..9bbf970858 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -351,7 +351,7 @@ export function SideMenu({ @@ -359,7 +359,7 @@ export function SideMenu({ } @@ -367,7 +367,7 @@ export function SideMenu({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index f84199639d..d39536d20f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -42,6 +42,7 @@ import { EnvironmentParamSchema, organizationBillingPath, } from "~/utils/pathBuilder"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; export const meta: MetaFunction = () => { return [ @@ -207,7 +208,12 @@ function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) { Concurrency - + Manage concurrency diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 9f4e4381b8..d7ee335694 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -160,6 +160,9 @@ const batches = colors.pink[500]; const schedules = colors.yellow[500]; const queues = colors.purple[500]; const deployments = colors.green[500]; +const concurrency = colors.amber[500]; +const limits = colors.purple[500]; +const regions = colors.green[500]; const logs = colors.blue[500]; const tests = colors.lime[500]; const apiKeys = colors.amber[500]; @@ -235,7 +238,10 @@ module.exports = { runs, batches, schedules, + concurrency, queues, + regions, + limits, deployments, logs, tests, From bc48352ef856590ce68d4ad80289287a417bc86b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 20:50:56 +0000 Subject: [PATCH 09/37] Adds missing upgrade buttons --- .../presenters/v3/LimitsPresenter.server.ts | 11 +- .../route.tsx | 112 +++++++++++------- 2 files changed, 82 insertions(+), 41 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index 873c36c7bc..60b0b006e3 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -39,6 +39,7 @@ export type QuotaInfo = { limit: number | null; currentUsage: number; source: "default" | "plan" | "override"; + canExceed?: boolean; }; // Types for feature flags @@ -76,6 +77,7 @@ export type LimitsResult = { }; planName: string | null; organizationId: string; + isOnTopPlan: boolean; }; export class LimitsPresenter extends BasePresenter { @@ -116,6 +118,7 @@ export class LimitsPresenter extends BasePresenter { // Get current plan from billing service const currentPlan = await getCurrentPlan(organizationId); const limits = currentPlan?.v3Subscription?.plan?.limits; + const isOnTopPlan = currentPlan?.v3Subscription?.plan?.code === "v3_pro_1"; // Resolve API rate limit config const apiRateLimitConfig = resolveApiRateLimitConfig(organization.apiRateLimiterConfig); @@ -192,6 +195,7 @@ export class LimitsPresenter extends BasePresenter { const supportLevel = limits?.support ?? "community"; return { + isOnTopPlan, rateLimits: { api: { name: "API Rate Limit", @@ -224,6 +228,7 @@ export class LimitsPresenter extends BasePresenter { limit: schedulesLimit, currentUsage: scheduleCount, source: "plan", + canExceed: limits?.schedules?.canExceed, } : null, teamMembers: @@ -234,6 +239,7 @@ export class LimitsPresenter extends BasePresenter { limit: teamMembersLimit, currentUsage: organization._count.members, source: "plan", + canExceed: limits?.teamMembers?.canExceed, } : null, alerts: @@ -244,6 +250,7 @@ export class LimitsPresenter extends BasePresenter { limit: alertsLimit, currentUsage: alertChannelCount, source: "plan", + canExceed: limits?.alerts?.canExceed, } : null, branches: @@ -254,6 +261,7 @@ export class LimitsPresenter extends BasePresenter { limit: branchesLimit, currentUsage: activeBranchCount, source: "plan", + canExceed: limits?.branches?.canExceed, } : null, logRetentionDays: @@ -270,10 +278,11 @@ export class LimitsPresenter extends BasePresenter { realtimeConnectionsLimit !== null ? { name: "Realtime connections", - description: "Maximum concurrent realtime connections", + description: "Maximum concurrent Realtime connections", limit: realtimeConnectionsLimit, currentUsage: 0, // Would need to query realtime service for this source: "plan", + canExceed: limits?.realtimeConcurrentConnections?.canExceed, } : null, devQueueSize: { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index d39536d20f..6aedbb76b7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -125,7 +125,8 @@ export default function Page() { {/* Current Plan Section */} {data.planName && ( )} @@ -147,14 +148,14 @@ export default function Page() { {/* Features Section */}
@@ -164,9 +165,15 @@ export default function Page() { ); } -function CurrentPlanSection({ planName, billingPath }: { planName: string; billingPath: string }) { - const isPro = planName === "Pro"; - +function CurrentPlanSection({ + planName, + isOnTopPlan, + billingPath, +}: { + planName: string; + isOnTopPlan: boolean; + billingPath: string; +}) { return (
Current plan @@ -175,7 +182,7 @@ function CurrentPlanSection({ planName, billingPath }: { planName: string; billi {planName} - {isPro ? ( + {isOnTopPlan ? ( Request Enterprise} defaultValue="help" @@ -429,15 +436,14 @@ function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) function QuotasSection({ quotas, batchConcurrency, - planName, + isOnTopPlan, billingPath, }: { quotas: LimitsResult["quotas"]; batchConcurrency: LimitsResult["batchConcurrency"]; - planName: string | null; + isOnTopPlan: boolean; billingPath: string; }) { - const isPro = planName === "Pro"; // Collect all quotas that should be shown const quotaRows: QuotaInfo[] = []; @@ -480,8 +486,7 @@ function QuotasSection({ ))} @@ -504,7 +509,7 @@ function QuotasSection({
- {isPro ? ( + {isOnTopPlan ? ( Contact us} defaultValue="help" @@ -525,13 +530,11 @@ function QuotasSection({ function QuotaRow({ quota, - showUpgrade, - isPro, + isOnTopPlan, billingPath, }: { quota: QuotaInfo; - showUpgrade: boolean; - isPro: boolean; + isOnTopPlan: boolean; billingPath: string; }) { // For log retention, we don't show current usage as it's a duration, not a count @@ -539,6 +542,50 @@ function QuotaRow({ const percentage = !isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null; + // Quotas that support upgrade options + const upgradableQuotas = [ + "Projects", + "Schedules", + "Team members", + "Alert channels", + "Preview branches", + "Realtime connections", + ]; + + const isUpgradable = upgradableQuotas.includes(quota.name); + + const renderUpgrade = () => { + if (!isUpgradable) { + return null; + } + + // Not on top plan - show View plans + if (!isOnTopPlan) { + return ( +
+ + View plans + +
+ ); + } + + // On top plan - show Contact us if canExceed is true (or for Projects which is always exceedable) + if (quota.canExceed || quota.name === "Projects") { + return ( +
+ Contact us} + defaultValue="help" + /> +
+ ); + } + + // On top plan but cannot exceed - no upgrade option + return null; + }; + return ( @@ -564,37 +611,22 @@ function QuotaRow({ - - {showUpgrade ? ( -
- {isPro ? ( - Contact us} - defaultValue="help" - /> - ) : ( - - View plans - - )} -
- ) : null} -
+ {renderUpgrade()}
); } function FeaturesSection({ features, - planName, + isOnTopPlan, billingPath, }: { features: LimitsResult["features"]; - planName: string | null; + isOnTopPlan: boolean; billingPath: string; }) { - const isPro = planName === "Pro"; - const isFree = planName === "Free" || planName === null; + // For staging environment: show View plans if not enabled (i.e., on Free plan) + const stagingUpgradeType = features.hasStagingEnvironment.enabled ? "none" : "view-plans"; return (
@@ -610,17 +642,17 @@ function FeaturesSection({ From 0a56dfee958e369138093167c24b48b29c99a70a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 21:46:00 +0000 Subject: [PATCH 10/37] Title consistency --- apps/webapp/app/presenters/v3/LimitsPresenter.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index 60b0b006e3..c4fb437e08 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -198,14 +198,14 @@ export class LimitsPresenter extends BasePresenter { isOnTopPlan, rateLimits: { api: { - name: "API Rate Limit", + name: "API rate limit", description: "Rate limit for API requests (trigger, batch, etc.)", config: apiRateLimitConfig, source: apiRateLimitSource, currentTokens: apiRateLimitTokens, }, batch: { - name: "Batch Rate Limit", + name: "Batch rate limit", description: "Rate limit for batch trigger operations", config: batchRateLimitConfig, source: batchRateLimitSource, From 1904763759d1ecfe3b16bd7d765ad2e58b871c95 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 13 Jan 2026 21:46:09 +0000 Subject: [PATCH 11/37] Adds more icons to headers --- .../route.tsx | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 6aedbb76b7..8f281388ec 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -1,4 +1,6 @@ import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { IconCrown, IconTallymark4 } from "@tabler/icons-react"; +import { Gauge, Gem } from "lucide-react"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; @@ -176,7 +178,10 @@ function CurrentPlanSection({ }) { return (
- Current plan + + + Current plan + @@ -203,7 +208,8 @@ function CurrentPlanSection({ function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) { return (
- + + Concurrency limits Concurrency - + Manage concurrency @@ -245,8 +246,9 @@ function RateLimitsSection({ return (
- - Rate Limits + + + Rate limits - Rate Limit + Rate limit Type @@ -465,6 +467,7 @@ function QuotasSection({ return (
+ Quotas - Plan Features + + + Plan features +
@@ -759,11 +765,11 @@ function SourceBadge({ source }: { source: "default" | "plan" | "override" }) { const variants: Record = { default: { label: "Default", - className: "bg-charcoal-700 text-text-dimmed", + className: "bg-indigo-500/20 text-indigo-400", }, plan: { label: "Plan", - className: "bg-indigo-500/20 text-indigo-400", + className: "bg-purple-500/20 text-purple-400", }, override: { label: "Override", From 97d9d2aeaa7a256346de154aebb64689e580eef6 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 10:34:51 +0000 Subject: [PATCH 12/37] Updates Tabler to latest version --- apps/webapp/package.json | 2 +- pnpm-lock.yaml | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 175fb5b230..987e983862 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -111,7 +111,7 @@ "@sentry/remix": "9.46.0", "@slack/web-api": "7.9.1", "@socket.io/redis-adapter": "^8.3.0", - "@tabler/icons-react": "^2.39.0", + "@tabler/icons-react": "^3.36.1", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-virtual": "^3.0.4", "@team-plain/typescript-sdk": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b6a862999..d2361acd82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,8 +463,8 @@ importers: specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.4(bufferutil@4.0.9)) '@tabler/icons-react': - specifier: ^2.39.0 - version: 2.47.0(react@18.2.0) + specifier: ^3.36.1 + version: 3.36.1(react@18.2.0) '@tailwindcss/container-queries': specifier: ^0.1.1 version: 0.1.1(tailwindcss@3.4.1) @@ -10167,13 +10167,13 @@ packages: resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} engines: {node: '>=6'} - '@tabler/icons-react@2.47.0': - resolution: {integrity: sha512-iqly2FvCF/qUbgmvS8E40rVeYY7laltc5GUjRxQj59DuX0x/6CpKHTXt86YlI2whg4czvd/c8Ce8YR08uEku0g==} + '@tabler/icons-react@3.36.1': + resolution: {integrity: sha512-/8nOXeNeMoze9xY/QyEKG65wuvRhkT3q9aytaur6Gj8bYU2A98YVJyLc9MRmc5nVvpy+bRlrrwK/Ykr8WGyUWg==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 + react: '>= 16' - '@tabler/icons@2.47.0': - resolution: {integrity: sha512-4w5evLh+7FUUiA1GucvGj2ReX2TvOjEr4ejXdwL/bsjoSkof6r1gQmzqI+VHrE2CpJpB3al7bCTulOkFa/RcyA==} + '@tabler/icons@3.36.1': + resolution: {integrity: sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==} '@tailwindcss/container-queries@0.1.1': resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==} @@ -30242,13 +30242,12 @@ snapshots: dependencies: defer-to-connect: 1.1.3 - '@tabler/icons-react@2.47.0(react@18.2.0)': + '@tabler/icons-react@3.36.1(react@18.2.0)': dependencies: - '@tabler/icons': 2.47.0 - prop-types: 15.8.1 + '@tabler/icons': 3.36.1 react: 18.2.0 - '@tabler/icons@2.47.0': {} + '@tabler/icons@3.36.1': {} '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.1)': dependencies: From ddee69ba23b79ebff7ede879f3471bcef5910b35 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 11:07:30 +0000 Subject: [PATCH 13/37] Icon update --- .../route.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 8f281388ec..d4a64b892a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -1,14 +1,15 @@ -import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; -import { IconCrown, IconTallymark4 } from "@tabler/icons-react"; -import { Gauge, Gem } from "lucide-react"; +import { CheckIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { IconCardsFilled, IconDiamondFilled, IconTallymark4 } from "@tabler/icons-react"; import { tryCatch } from "@trigger.dev/core"; +import { Gauge } from "lucide-react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Feedback } from "~/components/Feedback"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header2 } from "~/components/primitives/Headers"; @@ -44,7 +45,6 @@ import { EnvironmentParamSchema, organizationBillingPath, } from "~/utils/pathBuilder"; -import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; export const meta: MetaFunction = () => { return [ @@ -179,7 +179,7 @@ function CurrentPlanSection({ return (
- + Current plan
@@ -194,7 +194,7 @@ function CurrentPlanSection({ /> ) : ( - View plans to upgrade + View plans )} @@ -634,7 +634,7 @@ function FeaturesSection({ return (
- + Plan features
From 6c09cfe267576af81042042cd9370fa3f1b32bcd Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 11:07:56 +0000 Subject: [PATCH 14/37] Auto reload the page like the queues page --- .../route.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index d4a64b892a..2e1c8773ad 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -90,7 +90,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); } - return typedjson(result); + // Match the queues page pattern: pass a poll interval from the loader + const autoReloadPollIntervalMs = 5000; + + return typedjson({ + ...result, + autoReloadPollIntervalMs, + }); }; export default function Page() { @@ -99,8 +105,8 @@ export default function Page() { const project = useProject(); const environment = useEnvironment(); - // Auto-revalidate every 5 seconds to get fresh rate limit data - useAutoRevalidate({ interval: 5000 }); + // Auto-revalidate using the loader-provided interval and refresh on focus + useAutoRevalidate({ interval: data.autoReloadPollIntervalMs, onFocus: true }); return ( From f8abc1055554b2fda18cfec1aae2a0ae52004f87 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 11:08:28 +0000 Subject: [PATCH 15/37] Fix log retention upgrade logic --- .../route.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 2e1c8773ad..f9d0fe9311 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -551,6 +551,39 @@ function QuotaRow({ const percentage = !isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null; + // Special handling for Log retention + if (quota.name === "Log retention") { + const canUpgrade = !isOnTopPlan; + return ( + + + {quota.name} + + + + {quota.limit !== null ? `${formatNumber(quota.limit)} days` : "Unlimited"} + + + – + + + + + +
+ {canUpgrade ? ( + + View plans + + ) : ( + Max reached + )} +
+
+
+ ); + } + // Quotas that support upgrade options const upgradableQuotas = [ "Projects", From 11552ea9fb9fca95b0f17f3d3e83641bf0e8dd9e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 11:09:10 +0000 Subject: [PATCH 16/37] Magin top matches other pages --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index f9d0fe9311..efbe403e3a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -128,7 +128,7 @@ export default function Page() { -
+
{/* Current Plan Section */} {data.planName && ( From f2d6aaf2f93519d353d10e9e52fd751dc8399b52 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 11:12:16 +0000 Subject: [PATCH 17/37] Adds link to docs --- .../route.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index efbe403e3a..786218b5f1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -1,4 +1,4 @@ -import { CheckIcon } from "@heroicons/react/20/solid"; +import { CheckIcon, BookOpenIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { IconCardsFilled, IconDiamondFilled, IconTallymark4 } from "@tabler/icons-react"; @@ -42,6 +42,7 @@ import { cn } from "~/utils/cn"; import { formatNumber } from "~/utils/numberFormatter"; import { concurrencyPath, + docsPath, EnvironmentParamSchema, organizationBillingPath, } from "~/utils/pathBuilder"; @@ -125,6 +126,9 @@ export default function Page() { + + Limits docs + From 51d3bcf98f8d3c8a51b634ba3d68dfa7c2d28835 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 11:38:12 +0000 Subject: [PATCH 18/37] Batch processing table row now included in the map --- .../route.tsx | 43 +++++-------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 786218b5f1..2ddb8acf96 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -470,6 +470,16 @@ function QuotasSection({ if (quotas.realtimeConnections) quotaRows.push(quotas.realtimeConnections); if (quotas.logRetentionDays) quotaRows.push(quotas.logRetentionDays); + // Include batch processing concurrency as a quota row + quotaRows.push({ + name: "Batch processing concurrency", + description: "Controls how many batch items can be processed simultaneously.", + limit: batchConcurrency.limit, + currentUsage: 0, + source: batchConcurrency.source, + canExceed: true, // Allow contact us on top plan, view plans otherwise + }); + // Add queue size quotas if set if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize); if (quotas.deployedQueueSize.limit !== null) quotaRows.push(quotas.deployedQueueSize); @@ -503,38 +513,6 @@ function QuotasSection({ billingPath={billingPath} /> ))} - - - Batch processing concurrency - - - - {formatNumber(batchConcurrency.limit)} - - - – - - - - - -
- {isOnTopPlan ? ( - Contact us} - defaultValue="help" - /> - ) : ( - - View plans - - )} -
-
-
@@ -596,6 +574,7 @@ function QuotaRow({ "Alert channels", "Preview branches", "Realtime connections", + "Batch processing concurrency", ]; const isUpgradable = upgradableQuotas.includes(quota.name); From 20dbbd3e118fb7ee5105a902475aba3410e4ed09 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 11:41:41 +0000 Subject: [PATCH 19/37] Removes the Source column from the Rate Limits table --- apps/webapp/app/presenters/v3/LimitsPresenter.server.ts | 9 +-------- .../route.tsx | 4 ---- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index c4fb437e08..b915409401 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -28,7 +28,6 @@ export type RateLimitInfo = { name: string; description: string; config: RateLimiterConfig; - source: "default" | "plan" | "override"; currentTokens: number | null; }; @@ -120,13 +119,9 @@ export class LimitsPresenter extends BasePresenter { const limits = currentPlan?.v3Subscription?.plan?.limits; const isOnTopPlan = currentPlan?.v3Subscription?.plan?.code === "v3_pro_1"; - // Resolve API rate limit config + // Resolve rate limit configs (org override or default) const apiRateLimitConfig = resolveApiRateLimitConfig(organization.apiRateLimiterConfig); - const apiRateLimitSource = organization.apiRateLimiterConfig ? "override" : "default"; - - // Resolve batch rate limit config const batchRateLimitConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig); - const batchRateLimitSource = organization.batchRateLimitConfig ? "override" : "default"; // Resolve batch concurrency config const batchConcurrencyConfig = resolveBatchConcurrencyConfig( @@ -201,14 +196,12 @@ export class LimitsPresenter extends BasePresenter { name: "API rate limit", description: "Rate limit for API requests (trigger, batch, etc.)", config: apiRateLimitConfig, - source: apiRateLimitSource, currentTokens: apiRateLimitTokens, }, batch: { name: "Batch rate limit", description: "Rate limit for batch trigger operations", config: batchRateLimitConfig, - source: batchRateLimitSource, currentTokens: batchRateLimitTokens, }, }, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 2ddb8acf96..09167b7017 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -318,7 +318,6 @@ function RateLimitsSection({ /> - Source Upgrade @@ -371,9 +370,6 @@ function RateLimitRow({ info }: { info: RateLimitInfo }) { )} - - -
Date: Wed, 14 Jan 2026 11:44:42 +0000 Subject: [PATCH 20/37] Change button style to secondary --- .../route.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 09167b7017..dc2d72bce7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -373,7 +373,7 @@ function RateLimitRow({ info }: { info: RateLimitInfo }) {
Contact us} + button={} defaultValue="help" />
@@ -550,7 +550,7 @@ function QuotaRow({
{canUpgrade ? ( - + View plans ) : ( @@ -584,7 +584,7 @@ function QuotaRow({ if (!isOnTopPlan) { return (
- + View plans
@@ -596,7 +596,7 @@ function QuotaRow({ return (
Contact us} + button={} defaultValue="help" />
@@ -723,7 +723,7 @@ function FeatureRow({ case "view-plans": return (
- + View plans
@@ -732,7 +732,7 @@ function FeatureRow({ return (
Contact us} + button={} defaultValue="help" />
From 80448ea9f6f99a80ff013d185faf087211969008 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 11:50:13 +0000 Subject: [PATCH 21/37] Change the Upgrade button logic for batch rate limit to be plan based --- .../route.tsx | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index dc2d72bce7..724d93e0b8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -151,6 +151,8 @@ export default function Page() { {/* Rate Limits Section */} ; project: ReturnType; environment: ReturnType; @@ -322,15 +328,27 @@ function RateLimitsSection({ - - + +
); } -function RateLimitRow({ info }: { info: RateLimitInfo }) { +function RateLimitRow({ + info, + isOnTopPlan, + billingPath, +}: { + info: RateLimitInfo; + isOnTopPlan: boolean; + billingPath: string; +}) { const maxTokens = info.config.type === "tokenBucket" ? info.config.maxTokens : info.config.tokens; const percentage = info.currentTokens !== null && maxTokens > 0 ? info.currentTokens / maxTokens : null; @@ -372,10 +390,23 @@ function RateLimitRow({ info }: { info: RateLimitInfo }) {
- Contact us} - defaultValue="help" - /> + {info.name === "Batch rate limit" ? ( + isOnTopPlan ? ( + Contact us} + defaultValue="help" + /> + ) : ( + + View plans + + ) + ) : ( + Contact us} + defaultValue="help" + /> + )}
From 4b1baa89a6726a0f8968cbad668a4069e0382940 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 13:16:21 +0000 Subject: [PATCH 22/37] Show the Contact us button on the Project row --- .../route.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 724d93e0b8..3f45839534 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -132,7 +132,7 @@ export default function Page() { -
+
{/* Current Plan Section */} {data.planName && ( @@ -607,6 +607,18 @@ function QuotaRow({ const isUpgradable = upgradableQuotas.includes(quota.name); const renderUpgrade = () => { + // Projects always show Contact us (regardless of upgrade flags) + if (quota.name === "Projects") { + return ( +
+ Contact us} + defaultValue="help" + /> +
+ ); + } + if (!isUpgradable) { return null; } @@ -622,8 +634,8 @@ function QuotaRow({ ); } - // On top plan - show Contact us if canExceed is true (or for Projects which is always exceedable) - if (quota.canExceed || quota.name === "Projects") { + // On top plan - show Contact us if canExceed is true + if (quota.canExceed) { return (
Date: Wed, 14 Jan 2026 13:21:45 +0000 Subject: [PATCH 23/37] Keep Quota logic in the presenter --- .../app/presenters/v3/LimitsPresenter.server.ts | 7 +++++++ .../route.tsx | 16 ++-------------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index b915409401..702ad46666 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -39,6 +39,7 @@ export type QuotaInfo = { currentUsage: number; source: "default" | "plan" | "override"; canExceed?: boolean; + isUpgradable?: boolean; }; // Types for feature flags @@ -212,6 +213,7 @@ export class LimitsPresenter extends BasePresenter { limit: organization.maximumProjectCount, currentUsage: organization._count.projects, source: "default", + isUpgradable: true, }, schedules: schedulesLimit !== null @@ -222,6 +224,7 @@ export class LimitsPresenter extends BasePresenter { currentUsage: scheduleCount, source: "plan", canExceed: limits?.schedules?.canExceed, + isUpgradable: true, } : null, teamMembers: @@ -233,6 +236,7 @@ export class LimitsPresenter extends BasePresenter { currentUsage: organization._count.members, source: "plan", canExceed: limits?.teamMembers?.canExceed, + isUpgradable: true, } : null, alerts: @@ -244,6 +248,7 @@ export class LimitsPresenter extends BasePresenter { currentUsage: alertChannelCount, source: "plan", canExceed: limits?.alerts?.canExceed, + isUpgradable: true, } : null, branches: @@ -255,6 +260,7 @@ export class LimitsPresenter extends BasePresenter { currentUsage: activeBranchCount, source: "plan", canExceed: limits?.branches?.canExceed, + isUpgradable: true, } : null, logRetentionDays: @@ -276,6 +282,7 @@ export class LimitsPresenter extends BasePresenter { currentUsage: 0, // Would need to query realtime service for this source: "plan", canExceed: limits?.realtimeConcurrentConnections?.canExceed, + isUpgradable: true, } : null, devQueueSize: { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 3f45839534..327294acc4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -505,6 +505,7 @@ function QuotasSection({ currentUsage: 0, source: batchConcurrency.source, canExceed: true, // Allow contact us on top plan, view plans otherwise + isUpgradable: true, }); // Add queue size quotas if set @@ -593,19 +594,6 @@ function QuotaRow({ ); } - // Quotas that support upgrade options - const upgradableQuotas = [ - "Projects", - "Schedules", - "Team members", - "Alert channels", - "Preview branches", - "Realtime connections", - "Batch processing concurrency", - ]; - - const isUpgradable = upgradableQuotas.includes(quota.name); - const renderUpgrade = () => { // Projects always show Contact us (regardless of upgrade flags) if (quota.name === "Projects") { @@ -619,7 +607,7 @@ function QuotaRow({ ); } - if (!isUpgradable) { + if (!quota.isUpgradable) { return null; } From 946f49234b601a94381a099e5aa4e1fd504c74f2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 13:25:28 +0000 Subject: [PATCH 24/37] Log retention shows contact us button on Pro --- .../route.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 327294acc4..a8410e865c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -586,7 +586,10 @@ function QuotaRow({ View plans ) : ( - Max reached + Contact us} + defaultValue="help" + /> )}
From 18b660b3cdfdcafd6af04aac64bf0aefd3c93527 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 13:37:17 +0000 Subject: [PATCH 25/37] Improved rate limit badge styles --- .../route.tsx | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index a8410e865c..22a2a64da9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -287,23 +287,23 @@ function RateLimitsSection({ -
- Token bucket - +
+ + Requests consume tokens from a bucket that refills over time. When empty, requests are rate limited.
-
- Fixed window - +
+ + Allows a set number of requests per time window. The window resets at fixed intervals.
-
- Sliding window - +
+ + Allows a set number of requests per rolling time window. The limit is continuously evaluated. @@ -413,26 +413,35 @@ function RateLimitRow({ ); } -function RateLimitTypeBadge({ config }: { config: RateLimitInfo["config"] }) { - switch (config.type) { +function RateLimitTypeBadge({ + config, + type, +}: { + config?: RateLimitInfo["config"]; + type?: "tokenBucket" | "fixedWindow" | "slidingWindow"; +}) { + const rateLimitType = type ?? config?.type; + switch (rateLimitType) { case "tokenBucket": return ( - + Token bucket ); case "fixedWindow": return ( - + Fixed window ); case "slidingWindow": return ( - + Sliding window ); + default: + return null; } } From 2c6127cba7818fe47749a973eadbfa93470a3fed Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 14:31:39 +0000 Subject: [PATCH 26/37] Improved the available rate limit logic --- .../presenters/v3/LimitsPresenter.server.ts | 95 ++++--------------- .../runEngine/concerns/batchLimits.server.ts | 17 +--- ...authorizationRateLimitMiddleware.server.ts | 25 ++--- 3 files changed, 39 insertions(+), 98 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index 702ad46666..a98e82b9bf 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -1,19 +1,20 @@ +import { Ratelimit } from "@upstash/ratelimit"; import { createHash } from "node:crypto"; import { env } from "~/env.server"; -import { createRedisClient } from "~/redis.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; import { RateLimiterConfig, + createLimiterFromConfig, type RateLimitTokenBucketConfig, } from "~/services/authorizationRateLimitMiddleware.server"; -import type { Duration } from "~/services/rateLimiter.server"; +import { createRedisRateLimitClient, type Duration } from "~/services/rateLimiter.server"; import { BasePresenter } from "./basePresenter.server"; import { singleton } from "~/utils/singleton"; import { logger } from "~/services/logger.server"; // Create a singleton Redis client for rate limit queries -const rateLimitRedis = singleton("rateLimitQueryRedis", () => - createRedisClient("trigger:rateLimitQuery", { +const rateLimitRedisClient = singleton("rateLimitQueryRedisClient", () => + createRedisRateLimitClient({ port: env.RATE_LIMIT_REDIS_PORT, host: env.RATE_LIMIT_REDIS_HOST, username: env.RATE_LIMIT_REDIS_USERNAME, @@ -391,11 +392,8 @@ function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): { } /** - * Query Redis for the current remaining tokens for a rate limiter. - * The @upstash/ratelimit library stores token bucket state in Redis. - * Key format: ratelimit:{prefix}:{hashedIdentifier} - * - * For token bucket, the value is stored as: "tokens:lastRefillTime" + * Query the current remaining tokens for a rate limiter using the Upstash getRemaining method. + * This uses the same configuration and hashing logic as the rate limit middleware. */ async function getRateLimitRemainingTokens( keyPrefix: string, @@ -409,47 +407,20 @@ async function getRateLimitRemainingTokens( hash.update(authorizationValue); const hashedKey = hash.digest("hex"); - const redis = rateLimitRedis; - const redisKey = `ratelimit:${keyPrefix}:${hashedKey}`; - - // Get the stored value from Redis - const value = await redis.get(redisKey); - - if (!value) { - // No rate limit data yet - return max tokens (bucket is full) - if (config.type === "tokenBucket") { - return config.maxTokens; - } else if (config.type === "fixedWindow" || config.type === "slidingWindow") { - return config.tokens; - } - return null; - } - - // For token bucket, the @upstash/ratelimit library stores: "tokens:timestamp" - // Parse the value to get remaining tokens - if (typeof value === "string") { - const parts = value.split(":"); - if (parts.length >= 1) { - const tokens = parseInt(parts[0], 10); - if (!isNaN(tokens)) { - // For token bucket, we need to calculate current tokens based on refill - if (config.type === "tokenBucket" && parts.length >= 2) { - const lastRefillTime = parseInt(parts[1], 10); - if (!isNaN(lastRefillTime)) { - const now = Date.now(); - const elapsed = now - lastRefillTime; - const intervalMs = durationToMs(config.interval); - const tokensToAdd = Math.floor(elapsed / intervalMs) * config.refillRate; - const currentTokens = Math.min(tokens + tokensToAdd, config.maxTokens); - return Math.max(0, currentTokens); - } - } - return Math.max(0, tokens); - } - } - } + // Create a Ratelimit instance with the same configuration + const limiter = createLimiterFromConfig(config); + const ratelimit = new Ratelimit({ + redis: rateLimitRedisClient, + limiter, + ephemeralCache: new Map(), + analytics: false, + prefix: `ratelimit:${keyPrefix}`, + }); - return null; + // Use the getRemaining method to get the current remaining tokens + // getRemaining returns a Promise + const remaining = await ratelimit.getRemaining(hashedKey); + return remaining; } catch (error) { logger.warn("Failed to get rate limit remaining tokens", { keyPrefix, @@ -458,29 +429,3 @@ async function getRateLimitRemainingTokens( return null; } } - -/** - * Convert a duration string (e.g., "1s", "10s", "1m") to milliseconds - */ -function durationToMs(duration: Duration): number { - const match = duration.match(/^(\d+)(ms|s|m|h|d)$/); - if (!match) return 1000; // default to 1 second - - const value = parseInt(match[1], 10); - const unit = match[2]; - - switch (unit) { - case "ms": - return value; - case "s": - return value * 1000; - case "m": - return value * 60 * 1000; - case "h": - return value * 60 * 60 * 1000; - case "d": - return value * 24 * 60 * 60 * 1000; - default: - return 1000; - } -} diff --git a/apps/webapp/app/runEngine/concerns/batchLimits.server.ts b/apps/webapp/app/runEngine/concerns/batchLimits.server.ts index 437feadf38..f40088039e 100644 --- a/apps/webapp/app/runEngine/concerns/batchLimits.server.ts +++ b/apps/webapp/app/runEngine/concerns/batchLimits.server.ts @@ -1,8 +1,10 @@ import { Organization } from "@trigger.dev/database"; -import { Ratelimit } from "@upstash/ratelimit"; import { z } from "zod"; import { env } from "~/env.server"; -import { RateLimiterConfig } from "~/services/authorizationRateLimitMiddleware.server"; +import { + RateLimiterConfig, + createLimiterFromConfig, +} from "~/services/authorizationRateLimitMiddleware.server"; import { createRedisRateLimitClient, Duration, RateLimiter } from "~/services/rateLimiter.server"; import { singleton } from "~/utils/singleton"; @@ -33,16 +35,7 @@ function createBatchLimitsRedisClient() { function createOrganizationRateLimiter(organization: Organization): RateLimiter { const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig); - const limiter = - limiterConfig.type === "fixedWindow" - ? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window) - : limiterConfig.type === "tokenBucket" - ? Ratelimit.tokenBucket( - limiterConfig.refillRate, - limiterConfig.interval, - limiterConfig.maxTokens - ) - : Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window); + const limiter = createLimiterFromConfig(limiterConfig); return new RateLimiter({ redisClient: batchLimitsRedisClient, diff --git a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts index b94a664a36..2d4cc8b1f2 100644 --- a/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts +++ b/apps/webapp/app/services/authorizationRateLimitMiddleware.server.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { env } from "~/env.server"; import { RedisWithClusterOptions } from "~/redis.server"; import { logger } from "./logger.server"; -import { createRedisRateLimitClient, Duration, RateLimiter } from "./rateLimiter.server"; +import { createRedisRateLimitClient, Duration, Limiter, RateLimiter } from "./rateLimiter.server"; import { RedisCacheStore } from "./unkey/redisCacheStore.server"; const DurationSchema = z.custom((value) => { @@ -130,6 +130,18 @@ async function resolveLimitConfig( return cacheResult.val ?? defaultLimiter; } +/** + * Creates a Ratelimit limiter from a RateLimiterConfig. + * This function is shared across the codebase to ensure consistent limiter creation. + */ +export function createLimiterFromConfig(config: RateLimiterConfig): Limiter { + return config.type === "fixedWindow" + ? Ratelimit.fixedWindow(config.tokens, config.window) + : config.type === "tokenBucket" + ? Ratelimit.tokenBucket(config.refillRate, config.interval, config.maxTokens) + : Ratelimit.slidingWindow(config.tokens, config.window); +} + //returns an Express middleware that rate limits using the Bearer token in the Authorization header export function authorizationRateLimitMiddleware({ redis, @@ -249,16 +261,7 @@ export function authorizationRateLimitMiddleware({ limiterConfigOverride ); - const limiter = - limiterConfig.type === "fixedWindow" - ? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window) - : limiterConfig.type === "tokenBucket" - ? Ratelimit.tokenBucket( - limiterConfig.refillRate, - limiterConfig.interval, - limiterConfig.maxTokens - ) - : Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window); + const limiter = createLimiterFromConfig(limiterConfig); const rateLimiter = new RateLimiter({ redisClient, From 88bfbd5b0e45ea7d66ce6565eebd21f750138baa Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 15:37:23 +0000 Subject: [PATCH 27/37] =?UTF-8?q?Animate=20the=20rate=20limit=20=E2=80=9CA?= =?UTF-8?q?vailable=E2=80=9D=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../route.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 22a2a64da9..2911161328 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -10,6 +10,7 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { Feedback } from "~/components/Feedback"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector"; +import { AnimatedNumber } from "~/components/primitives/AnimatedNumber"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header2 } from "~/components/primitives/Headers"; @@ -378,7 +379,7 @@ function RateLimitRow({ getUsageColorClass(percentage, "remaining") )} > - {formatNumber(info.currentTokens)} + of {formatNumber(maxTokens)} From ee8b5f7f25c7dc67f95eb1b63102d2b94ecac4e3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 15:37:53 +0000 Subject: [PATCH 28/37] Adds a rate limit stress test task to references --- .../src/trigger/rateLimitStress.ts | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 references/hello-world/src/trigger/rateLimitStress.ts diff --git a/references/hello-world/src/trigger/rateLimitStress.ts b/references/hello-world/src/trigger/rateLimitStress.ts new file mode 100644 index 0000000000..cb2c20c579 --- /dev/null +++ b/references/hello-world/src/trigger/rateLimitStress.ts @@ -0,0 +1,257 @@ +import { logger, task, tasks, RateLimitError } from "@trigger.dev/sdk/v3"; +import { setTimeout } from "timers/promises"; + +/** + * A simple no-op task that does minimal work. + * Used as the target for rate limit stress testing. + */ +export const noopTask = task({ + id: "noop-task", + retry: { maxAttempts: 1 }, + run: async (payload: { index: number }) => { + return { index: payload.index, timestamp: Date.now() }; + }, +}); + +/** + * Stress test task that triggers many runs rapidly to hit the API rate limit. + * Fires triggers as fast as possible for a set duration, then stops. + * + * Note: Already-triggered runs will continue to execute after the test completes. + * + * Default rate limits (per environment API key): + * - Free: 1,200 runs bucket, refills 100 runs/10 sec + * - Hobby/Pro: 5,000 runs bucket, refills 500 runs/5 sec + * + * Run with: `npx trigger.dev@latest dev` then trigger this task from the dashboard + */ +export const rateLimitStressTest = task({ + id: "rate-limit-stress-test", + maxDuration: 120, + run: async (payload: { + /** How long to run the test in seconds (default: 5) */ + durationSeconds?: number; + /** How many triggers to fire in parallel per batch (default: 100) */ + batchSize?: number; + }) => { + const durationSeconds = payload.durationSeconds ?? 5; + const batchSize = payload.batchSize ?? 100; + const durationMs = durationSeconds * 1000; + + logger.info("Starting rate limit stress test", { + durationSeconds, + batchSize, + }); + + const start = Date.now(); + let totalAttempted = 0; + let totalSuccess = 0; + let totalRateLimited = 0; + let totalOtherErrors = 0; + let batchCount = 0; + + // Keep firing batches until time runs out + while (Date.now() - start < durationMs) { + batchCount++; + const batchStart = Date.now(); + const elapsed = batchStart - start; + const remaining = durationMs - elapsed; + + logger.info(`Batch ${batchCount} starting`, { + elapsedMs: elapsed, + remainingMs: remaining, + totalAttempted, + totalSuccess, + totalRateLimited, + }); + + // Fire a batch of triggers + const promises = Array.from({ length: batchSize }, async (_, i) => { + // Check if we've exceeded time before each trigger + if (Date.now() - start >= durationMs) { + return { skipped: true }; + } + + const index = totalAttempted + i; + try { + await tasks.trigger("noop-task", { index }); + return { success: true, rateLimited: false }; + } catch (error) { + if (error instanceof RateLimitError) { + return { success: false, rateLimited: true, resetInMs: error.millisecondsUntilReset }; + } + return { success: false, rateLimited: false }; + } + }); + + const results = await Promise.all(promises); + + const batchSuccess = results.filter((r) => "success" in r && r.success).length; + const batchRateLimited = results.filter((r) => "rateLimited" in r && r.rateLimited).length; + const batchOtherErrors = results.filter( + (r) => "success" in r && !r.success && !("rateLimited" in r && r.rateLimited) + ).length; + const batchSkipped = results.filter((r) => "skipped" in r && r.skipped).length; + + totalAttempted += batchSize - batchSkipped; + totalSuccess += batchSuccess; + totalRateLimited += batchRateLimited; + totalOtherErrors += batchOtherErrors; + + // Log rate limit hits + const rateLimitedResult = results.find((r) => "rateLimited" in r && r.rateLimited); + if (rateLimitedResult && "resetInMs" in rateLimitedResult) { + logger.warn("Rate limit hit!", { + batch: batchCount, + resetInMs: rateLimitedResult.resetInMs, + totalRateLimited, + }); + } + + // Small delay between batches to not overwhelm + await setTimeout(50); + } + + const duration = Date.now() - start; + + logger.info("Stress test completed", { + actualDurationMs: duration, + totalAttempted, + totalSuccess, + totalRateLimited, + totalOtherErrors, + batchCount, + }); + + return { + config: { + durationSeconds, + batchSize, + }, + results: { + actualDurationMs: duration, + totalAttempted, + totalSuccess, + totalRateLimited, + totalOtherErrors, + batchCount, + hitRateLimit: totalRateLimited > 0, + triggersPerSecond: Math.round((totalAttempted / duration) * 1000), + }, + }; + }, +}); + +/** + * Sustained load test - maintains a steady rate of triggers over time. + * Useful for seeing how rate limits behave under sustained load. + * + * Note: Successfully triggered runs will continue executing after this test completes. + */ +export const sustainedLoadTest = task({ + id: "sustained-load-test", + maxDuration: 300, + run: async (payload: { + /** Triggers per second to attempt (default: 100) */ + triggersPerSecond?: number; + /** Duration in seconds (default: 20) */ + durationSeconds?: number; + }) => { + const triggersPerSecond = payload.triggersPerSecond ?? 100; + const durationSeconds = payload.durationSeconds ?? 20; + + const intervalMs = 1000 / triggersPerSecond; + const totalTriggers = triggersPerSecond * durationSeconds; + + logger.info("Starting sustained load test", { + triggersPerSecond, + durationSeconds, + totalTriggers, + intervalMs, + }); + + const results: Array<{ + index: number; + success: boolean; + rateLimited: boolean; + timestamp: number; + }> = []; + + const start = Date.now(); + let index = 0; + + while (Date.now() - start < durationSeconds * 1000 && index < totalTriggers) { + const triggerStart = Date.now(); + + try { + await tasks.trigger("noop-task", { index }); + results.push({ + index, + success: true, + rateLimited: false, + timestamp: Date.now() - start, + }); + } catch (error) { + results.push({ + index, + success: false, + rateLimited: error instanceof RateLimitError, + timestamp: Date.now() - start, + }); + + if (error instanceof RateLimitError) { + logger.warn("Rate limit hit during sustained load", { + index, + timestamp: Date.now() - start, + resetInMs: error.millisecondsUntilReset, + }); + } + } + + index++; + + // Maintain the target rate + const elapsed = Date.now() - triggerStart; + const sleepTime = Math.max(0, intervalMs - elapsed); + if (sleepTime > 0) { + await setTimeout(sleepTime); + } + } + + const duration = Date.now() - start; + const successCount = results.filter((r) => r.success).length; + const rateLimitedCount = results.filter((r) => r.rateLimited).length; + + // Find when rate limiting started (if at all) + const firstRateLimited = results.find((r) => r.rateLimited); + + logger.info("Sustained load test completed", { + actualDuration: duration, + actualTriggers: results.length, + successCount, + rateLimitedCount, + actualRate: Math.round((results.length / duration) * 1000), + }); + + return { + config: { + targetTriggersPerSecond: triggersPerSecond, + targetDurationSeconds: durationSeconds, + }, + results: { + actualDuration: duration, + actualTriggers: results.length, + successCount, + rateLimitedCount, + actualRate: Math.round((results.length / duration) * 1000), + hitRateLimit: rateLimitedCount > 0, + firstRateLimitedAt: firstRateLimited + ? { + index: firstRateLimited.index, + timestampMs: firstRateLimited.timestamp, + } + : null, + }, + }; + }, +}); From ceba379182392bb2c60ec3cb9eda294d973bd4cf Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 17:38:36 +0000 Subject: [PATCH 29/37] Remove unused attributes --- .../route.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 2911161328..005b634ba9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -79,8 +79,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const presenter = new LimitsPresenter(); const [error, result] = await tryCatch( presenter.call({ - userId, - projectId: project.id, organizationId: project.organizationId, environmentApiKey: environment.apiKey, }) From 6d8632be126fe111fd52b63bf62b117987cf6c29 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 17:39:17 +0000 Subject: [PATCH 30/37] Use findFirstOrThrow instead --- apps/webapp/app/presenters/v3/LimitsPresenter.server.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index a98e82b9bf..515a42f3c4 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -83,18 +83,14 @@ export type LimitsResult = { export class LimitsPresenter extends BasePresenter { public async call({ - userId, - projectId, organizationId, environmentApiKey, }: { - userId: string; - projectId: string; organizationId: string; environmentApiKey: string; }): Promise { // Get organization with all limit-related fields - const organization = await this._replica.organization.findUniqueOrThrow({ + const organization = await this._replica.organization.findFirstOrThrow({ where: { id: organizationId }, select: { id: true, From 8b33c60758feda9b603f0140b14e4617f1622c61 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 17:52:42 +0000 Subject: [PATCH 31/37] More optimised query --- .../webapp/app/presenters/v3/LimitsPresenter.server.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index 515a42f3c4..67af83cd31 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -129,15 +129,11 @@ export class LimitsPresenter extends BasePresenter { ? "override" : "default"; - // Get schedule count for this org + // Get schedule count for this org (via project relation which has an index) const scheduleCount = await this._replica.taskSchedule.count({ where: { - instances: { - some: { - environment: { - organizationId, - }, - }, + project: { + organizationId, }, }, }); From 0027125a4501edad5ad9f774b30970852b54b9aa Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 14 Jan 2026 17:59:33 +0000 Subject: [PATCH 32/37] Improved query performance --- apps/webapp/app/presenters/v3/LimitsPresenter.server.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index 67af83cd31..75de67a8e4 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -147,12 +147,10 @@ export class LimitsPresenter extends BasePresenter { }, }); - // Get active branches count for this org + // Get active branches count for this org (uses @@index([organizationId])) const activeBranchCount = await this._replica.runtimeEnvironment.count({ where: { - project: { - organizationId, - }, + organizationId, branchName: { not: null, }, From 4c9a4d9d3a325846df5be107d3b932cbbb9b4647 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 15 Jan 2026 11:02:43 +0000 Subject: [PATCH 33/37] Added Todo for Matt --- apps/webapp/app/presenters/v3/LimitsPresenter.server.ts | 9 +++++---- .../route.tsx | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index 75de67a8e4..bf63699980 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -84,9 +84,11 @@ export type LimitsResult = { export class LimitsPresenter extends BasePresenter { public async call({ organizationId, + projectId, environmentApiKey, }: { organizationId: string; + projectId: string; environmentApiKey: string; }): Promise { // Get organization with all limit-related fields @@ -130,6 +132,7 @@ export class LimitsPresenter extends BasePresenter { : "default"; // Get schedule count for this org (via project relation which has an index) + //TODO we should change the way we count these and use the ScheduleListPresenter method const scheduleCount = await this._replica.taskSchedule.count({ where: { project: { @@ -141,16 +144,14 @@ export class LimitsPresenter extends BasePresenter { // Get alert channel count for this org const alertChannelCount = await this._replica.projectAlertChannel.count({ where: { - project: { - organizationId, - }, + projectId, }, }); // Get active branches count for this org (uses @@index([organizationId])) const activeBranchCount = await this._replica.runtimeEnvironment.count({ where: { - organizationId, + projectId, branchName: { not: null, }, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index 005b634ba9..c7ea36fb7b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -80,6 +80,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const [error, result] = await tryCatch( presenter.call({ organizationId: project.organizationId, + projectId: project.id, environmentApiKey: environment.apiKey, }) ); From 77cc95213182e69c1df18fa35fbdfff60bf0e17e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 Jan 2026 16:01:22 +0000 Subject: [PATCH 34/37] Check schedule limit properly. Improve some structure --- .../presenters/v3/LimitsPresenter.server.ts | 31 +++++++++---------- .../route.tsx | 15 ++------- .../app/v3/services/checkSchedule.server.ts | 23 ++++++-------- 3 files changed, 26 insertions(+), 43 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts index bf63699980..62f3395b7a 100644 --- a/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LimitsPresenter.server.ts @@ -11,6 +11,7 @@ import { createRedisRateLimitClient, type Duration } from "~/services/rateLimite import { BasePresenter } from "./basePresenter.server"; import { singleton } from "~/utils/singleton"; import { logger } from "~/services/logger.server"; +import { CheckScheduleService } from "~/v3/services/checkSchedule.server"; // Create a singleton Redis client for rate limit queries const rateLimitRedisClient = singleton("rateLimitQueryRedisClient", () => @@ -64,13 +65,10 @@ export type LimitsResult = { branches: QuotaInfo | null; logRetentionDays: QuotaInfo | null; realtimeConnections: QuotaInfo | null; + batchProcessingConcurrency: QuotaInfo; devQueueSize: QuotaInfo; deployedQueueSize: QuotaInfo; }; - batchConcurrency: { - limit: number; - source: "default" | "override"; - }; features: { hasStagingEnvironment: FeatureInfo; support: FeatureInfo; @@ -131,14 +129,10 @@ export class LimitsPresenter extends BasePresenter { ? "override" : "default"; - // Get schedule count for this org (via project relation which has an index) - //TODO we should change the way we count these and use the ScheduleListPresenter method - const scheduleCount = await this._replica.taskSchedule.count({ - where: { - project: { - organizationId, - }, - }, + // Get schedule count for this org + const scheduleCount = await CheckScheduleService.getUsedSchedulesCount({ + prisma: this._replica, + projectId, }); // Get alert channel count for this org @@ -277,6 +271,15 @@ export class LimitsPresenter extends BasePresenter { isUpgradable: true, } : null, + batchProcessingConcurrency: { + name: "Batch processing concurrency", + description: "Controls how many batch items can be processed simultaneously.", + limit: batchConcurrencyConfig.processingConcurrency, + currentUsage: 0, + source: batchConcurrencySource, + canExceed: true, + isUpgradable: true, + }, devQueueSize: { name: "Dev queue size", description: "Maximum pending runs in development environments", @@ -292,10 +295,6 @@ export class LimitsPresenter extends BasePresenter { source: organization.maximumDeployedQueueSize ? "override" : "default", }, }, - batchConcurrency: { - limit: batchConcurrencyConfig.processingConcurrency, - source: batchConcurrencySource, - }, features: { hasStagingEnvironment: { name: "Staging environment", diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx index c7ea36fb7b..1ca8778dba 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -161,7 +161,6 @@ export default function Page() { {/* Quotas Section */} @@ -483,12 +482,10 @@ function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) function QuotasSection({ quotas, - batchConcurrency, isOnTopPlan, billingPath, }: { quotas: LimitsResult["quotas"]; - batchConcurrency: LimitsResult["batchConcurrency"]; isOnTopPlan: boolean; billingPath: string; }) { @@ -506,16 +503,8 @@ function QuotasSection({ if (quotas.realtimeConnections) quotaRows.push(quotas.realtimeConnections); if (quotas.logRetentionDays) quotaRows.push(quotas.logRetentionDays); - // Include batch processing concurrency as a quota row - quotaRows.push({ - name: "Batch processing concurrency", - description: "Controls how many batch items can be processed simultaneously.", - limit: batchConcurrency.limit, - currentUsage: 0, - source: batchConcurrency.source, - canExceed: true, // Allow contact us on top plan, view plans otherwise - isUpgradable: true, - }); + // Include batch processing concurrency + quotaRows.push(quotas.batchProcessingConcurrency); // Add queue size quotas if set if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize); diff --git a/apps/webapp/app/v3/services/checkSchedule.server.ts b/apps/webapp/app/v3/services/checkSchedule.server.ts index 25a0944dc6..570e6e3bf1 100644 --- a/apps/webapp/app/v3/services/checkSchedule.server.ts +++ b/apps/webapp/app/v3/services/checkSchedule.server.ts @@ -92,7 +92,7 @@ export class CheckScheduleService extends BaseService { const limit = await getLimit(project.organizationId, "schedules", 100_000_000); const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({ prisma: this._prisma, - environments: project.environments, + projectId, }); if (schedulesCount >= limit) { @@ -105,26 +105,21 @@ export class CheckScheduleService extends BaseService { static async getUsedSchedulesCount({ prisma, - environments, + projectId, }: { prisma: PrismaClientOrTransaction; - environments: { id: string; type: RuntimeEnvironmentType; archivedAt: Date | null }[]; + projectId: string; }) { - const deployedEnvironments = environments.filter( - (env) => env.type !== "DEVELOPMENT" && !env.archivedAt - ); - const schedulesCount = await prisma.taskScheduleInstance.count({ + return await prisma.taskScheduleInstance.count({ where: { - environmentId: { - in: deployedEnvironments.map((env) => env.id), + projectId, + environment: { + type: { + not: "DEVELOPMENT", + }, }, active: true, - taskSchedule: { - active: true, - }, }, }); - - return schedulesCount; } } From 7fe18d49aee9fd4c14465fb58015fe228461af38 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 Jan 2026 16:01:59 +0000 Subject: [PATCH 35/37] TaskScheduleInstance.projectId not nullable. Backfill in the migration The backfill is for self-hosters, it will be a no-op for already filled rows (i.e. cloud) --- .../migration.sql | 21 +++++++++++++++++++ .../database/prisma/schema.prisma | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20260115154554_taskscheduleinstance_required_projectid/migration.sql diff --git a/internal-packages/database/prisma/migrations/20260115154554_taskscheduleinstance_required_projectid/migration.sql b/internal-packages/database/prisma/migrations/20260115154554_taskscheduleinstance_required_projectid/migration.sql new file mode 100644 index 0000000000..584b0b438a --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260115154554_taskscheduleinstance_required_projectid/migration.sql @@ -0,0 +1,21 @@ +/* +Warnings: + +- Made the column `projectId` on table `TaskScheduleInstance` required. This step will fail if there are existing NULL values in that column. + + */ +-- Backfill from TaskSchedule +UPDATE "TaskScheduleInstance" tsi +SET + "projectId" = ts."projectId" +FROM + "TaskSchedule" ts +WHERE + tsi."taskScheduleId" = ts."id" + AND tsi."projectId" IS NULL; + +-- AlterTable +ALTER TABLE "public"."TaskScheduleInstance" +ALTER COLUMN "projectId" +SET + NOT NULL; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 44c5409970..f4236913e1 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1946,8 +1946,8 @@ model TaskScheduleInstance { environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) environmentId String - project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) - projectId String? + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 3544e04d77339716ff0e38687f236e73b8911f54 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 Jan 2026 16:46:05 +0000 Subject: [PATCH 36/37] TaskScheduledInstance projectId + active index --- .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 1 + 2 files changed, 3 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20260115164415_taskscheduleinstance_required_projectid_index/migration.sql diff --git a/internal-packages/database/prisma/migrations/20260115164415_taskscheduleinstance_required_projectid_index/migration.sql b/internal-packages/database/prisma/migrations/20260115164415_taskscheduleinstance_required_projectid_index/migration.sql new file mode 100644 index 0000000000..cb130c0400 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260115164415_taskscheduleinstance_required_projectid_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskScheduleInstance_projectId_active_idx" ON "public"."TaskScheduleInstance" ("projectId", "active"); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index f4236913e1..451c9beb80 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1960,6 +1960,7 @@ model TaskScheduleInstance { //you can only have a schedule attached to each environment once @@unique([taskScheduleId, environmentId]) @@index([environmentId]) + @@index([projectId, active]) } model RuntimeEnvironmentSession { From ff698d94caf7bc8759d678406e00bd6ed8a1b627 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 Jan 2026 16:56:34 +0000 Subject: [PATCH 37/37] Fix for type error --- apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index 929e1e40ba..053414dcfc 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -96,7 +96,7 @@ export class ScheduleListPresenter extends BasePresenter { const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({ prisma: this._replica, - environments: project.environments, + projectId, }); const limit = await getLimit(project.organizationId, "schedules", 100_000_000);