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"; 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 { 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"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; 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 { useEnvironment } from "~/hooks/useEnvironment"; import { useShowSelfServe } from "~/hooks/useShowSelfServe"; 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 QuotaInfo, type RateLimitInfo, } from "~/presenters/v3/LimitsPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { formatNumber } from "~/utils/numberFormatter"; import { concurrencyPath, docsPath, EnvironmentParamSchema, organizationBillingPath, } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ { title: `Limits | Trigger.dev`, }, ]; }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response(undefined, { status: 404, statusText: "Project not found", }); } const 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({ organizationId: project.organizationId, projectId: project.id, environmentId: environment.id, environmentType: environment.type, environmentApiKey: environment.apiKey, }) ); if (error) { throw new Response(error.message, { status: 400, }); } // Match the queues page pattern: pass a poll interval from the loader const autoReloadPollIntervalMs = 5000; return typedjson({ ...result, autoReloadPollIntervalMs, }); }; export default function Page() { const data = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); // Auto-revalidate using the loader-provided interval and refresh on focus useAutoRevalidate({ interval: data.autoReloadPollIntervalMs, onFocus: true }); return ( Plan {data.planName ?? "No plan"} Organization ID {data.organizationId} Limits docs
{/* Current Plan Section */} {data.planName && ( )} {/* Concurrency Section */} {/* Rate Limits Section */} {/* Quotas Section */} {/* Features Section */}
); } function CurrentPlanSection({ planName, isOnTopPlan, billingPath, }: { planName: string; isOnTopPlan: boolean; billingPath: string; }) { const showSelfServe = useShowSelfServe(); return (
Current plan {planName} {isOnTopPlan ? ( Request Enterprise} defaultValue="help" /> ) : showSelfServe ? ( View plans ) : ( Contact us} /> )}
); } function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) { return (
Concurrency limits Concurrency Manage concurrency
); } function RateLimitsSection({ rateLimits, isOnTopPlan, billingPath, organization, project, environment, }: { rateLimits: LimitsResult["rateLimits"]; isOnTopPlan: boolean; billingPath: string; organization: ReturnType; project: ReturnType; environment: ReturnType; }) { const showSelfServe = useShowSelfServe(); return (
Rate limits
Rate limit Type
Requests consume tokens from a bucket that refills over time. When empty, requests are rate limited.
Allows a set number of requests per time window. The window resets at fixed intervals.
Allows a set number of requests per rolling time window. The limit is continuously evaluated.
} disableHoverableContent />
Configuration Available {showSelfServe ? Upgrade : null}
); } function RateLimitRow({ info, isOnTopPlan, billingPath, showSelfServe, }: { info: RateLimitInfo; isOnTopPlan: boolean; billingPath: string; showSelfServe: boolean; }) { const maxTokens = info.config.type === "tokenBucket" ? info.config.maxTokens : info.config.tokens; const percentage = info.currentTokens !== null && maxTokens > 0 ? info.currentTokens / maxTokens : null; return ( {info.name}
{info.currentTokens !== null ? (
of {formatNumber(maxTokens)}
) : ( )}
{showSelfServe ? (
{info.name === "Batch rate limit" ? ( isOnTopPlan ? ( Contact us} defaultValue="help" /> ) : ( View plans ) ) : ( Contact us} defaultValue="help" /> )}
) : null}
); } 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; } } 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, isOnTopPlan, billingPath, }: { quotas: LimitsResult["quotas"]; isOnTopPlan: boolean; billingPath: string; }) { // 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); // Include batch processing concurrency quotaRows.push(quotas.batchProcessingConcurrency); // Add queue size quota if set if (quotas.queueSize.limit !== null) quotaRows.push(quotas.queueSize); // Metric & query quotas if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards); if (quotas.metricWidgetsPerDashboard) quotaRows.push(quotas.metricWidgetsPerDashboard); if (quotas.queryPeriodDays) quotaRows.push(quotas.queryPeriodDays); const showSelfServe = useShowSelfServe(); return (
Quotas Quota Limit Current Source {showSelfServe ? Upgrade : null} {quotaRows.map((quota) => ( ))}
); } function QuotaRow({ quota, isOnTopPlan, billingPath, showSelfServe, }: { quota: QuotaInfo; isOnTopPlan: boolean; billingPath: string; showSelfServe: boolean; }) { // For log retention and query period, we don't show current usage as it's a duration, not a count // For widgets per dashboard, the usage varies per dashboard so we don't show a single number const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period"; const isPerItemQuota = quota.name === "Charts per dashboard"; const isRetentionQuota = isDurationQuota || isPerItemQuota; const isQueueSizeQuota = quota.name === "Max queued runs"; const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota; const percentage = !hideCurrentUsage && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null; // Special handling for duration-based quotas (Log retention, Query period) if (isDurationQuota) { const canUpgrade = !isOnTopPlan; return ( {quota.name} {quota.limit !== null ? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}` : "Unlimited"} {showSelfServe ? (
{canUpgrade ? ( View plans ) : ( Contact us} defaultValue="help" /> )}
) : null}
); } const renderUpgrade = () => { // Projects always show Contact us (regardless of upgrade flags) if (quota.name === "Projects") { return (
Contact us} defaultValue="help" />
); } if (!quota.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 if (quota.canExceed) { return (
Contact us} defaultValue="help" />
); } // On top plan but cannot exceed - no upgrade option return null; }; return ( {quota.name} {quota.limit !== null ? isDurationQuota ? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}` : formatNumber(quota.limit) : "Unlimited"} {hideCurrentUsage ? "–" : formatNumber(quota.currentUsage)} {showSelfServe ? {renderUpgrade()} : null} ); } function FeaturesSection({ features, isOnTopPlan, billingPath, }: { features: LimitsResult["features"]; isOnTopPlan: boolean; billingPath: string; }) { // For staging environment: show View plans if not enabled (i.e., on Free plan) const stagingUpgradeType = features.hasStagingEnvironment.enabled ? "none" : "view-plans"; const showSelfServe = useShowSelfServe(); return (
Plan features Feature Status {showSelfServe ? Upgrade : null}
); } function FeatureRow({ feature, upgradeType, billingPath, showSelfServe, }: { feature: FeatureInfo; upgradeType: "view-plans" | "contact-us" | "none"; billingPath: string; showSelfServe: boolean; }) { 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 ); }; const renderUpgrade = () => { switch (upgradeType) { case "view-plans": return (
View plans
); case "contact-us": return (
Contact us} defaultValue="help" />
); case "none": return null; } }; return ( {feature.name} {displayValue()} {showSelfServe ? {renderUpgrade()} : null} ); } /** * 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: { label: "Default", className: "bg-indigo-500/20 text-indigo-400", }, plan: { label: "Plan", className: "bg-purple-500/20 text-purple-400", }, override: { label: "Override", className: "bg-amber-500/20 text-amber-400", }, }; const variant = variants[source]; return ( {variant.label} ); }