Skip to content

Commit 9c13ad1

Browse files
committed
Very early Limits page
1 parent b696bbb commit 9c13ad1

File tree

4 files changed

+761
-0
lines changed

4 files changed

+761
-0
lines changed

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AdjustmentsHorizontalIcon,
23
ArrowPathRoundedSquareIcon,
34
ArrowRightOnRectangleIcon,
45
BeakerIcon,
@@ -50,6 +51,7 @@ import {
5051
adminPath,
5152
branchesPath,
5253
concurrencyPath,
54+
limitsPath,
5355
logoutPath,
5456
newOrganizationPath,
5557
newProjectPath,
@@ -362,6 +364,13 @@ export function SideMenu({
362364
data-action="regions"
363365
badge={<V4Badge />}
364366
/>
367+
<SideMenuItem
368+
name="Limits"
369+
icon={AdjustmentsHorizontalIcon}
370+
activeIconColor="text-purple-500"
371+
to={limitsPath(organization, project, environment)}
372+
data-action="limits"
373+
/>
365374
<SideMenuItem
366375
name="Project settings"
367376
icon={Cog8ToothIcon}
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
2+
import { env } from "~/env.server";
3+
import { getCurrentPlan, getDefaultEnvironmentLimitFromPlan } from "~/services/platform.v3.server";
4+
import {
5+
RateLimiterConfig,
6+
type RateLimitTokenBucketConfig,
7+
} from "~/services/authorizationRateLimitMiddleware.server";
8+
import type { Duration } from "~/services/rateLimiter.server";
9+
import { BasePresenter } from "./basePresenter.server";
10+
import { sortEnvironments } from "~/utils/environmentSort";
11+
import { engine } from "~/v3/runEngine.server";
12+
13+
// Types for rate limit display
14+
export type RateLimitInfo = {
15+
name: string;
16+
description: string;
17+
config: RateLimiterConfig;
18+
source: "default" | "plan" | "override";
19+
};
20+
21+
// Types for concurrency limit display
22+
export type ConcurrencyLimitInfo = {
23+
environmentId: string;
24+
environmentType: RuntimeEnvironmentType;
25+
branchName: string | null;
26+
limit: number;
27+
currentUsage: number;
28+
planLimit: number;
29+
source: "default" | "plan" | "override";
30+
};
31+
32+
// Types for quota display
33+
export type QuotaInfo = {
34+
name: string;
35+
description: string;
36+
limit: number | null;
37+
currentUsage: number;
38+
source: "default" | "plan" | "override";
39+
};
40+
41+
export type LimitsResult = {
42+
rateLimits: {
43+
api: RateLimitInfo;
44+
batch: RateLimitInfo;
45+
};
46+
concurrencyLimits: ConcurrencyLimitInfo[];
47+
quotas: {
48+
projects: QuotaInfo;
49+
schedules: QuotaInfo | null;
50+
devQueueSize: QuotaInfo;
51+
deployedQueueSize: QuotaInfo;
52+
};
53+
batchConcurrency: {
54+
limit: number;
55+
source: "default" | "override";
56+
};
57+
planName: string | null;
58+
};
59+
60+
export class LimitsPresenter extends BasePresenter {
61+
public async call({
62+
userId,
63+
projectId,
64+
organizationId,
65+
}: {
66+
userId: string;
67+
projectId: string;
68+
organizationId: string;
69+
}): Promise<LimitsResult> {
70+
// Get organization with all limit-related fields
71+
const organization = await this._replica.organization.findUniqueOrThrow({
72+
where: { id: organizationId },
73+
select: {
74+
id: true,
75+
maximumConcurrencyLimit: true,
76+
maximumProjectCount: true,
77+
maximumDevQueueSize: true,
78+
maximumDeployedQueueSize: true,
79+
apiRateLimiterConfig: true,
80+
batchRateLimitConfig: true,
81+
batchQueueConcurrencyConfig: true,
82+
_count: {
83+
select: {
84+
projects: {
85+
where: { deletedAt: null },
86+
},
87+
},
88+
},
89+
},
90+
});
91+
92+
// Get current plan from billing service
93+
const currentPlan = await getCurrentPlan(organizationId);
94+
95+
// Get environments for this project
96+
const environments = await this._replica.runtimeEnvironment.findMany({
97+
select: {
98+
id: true,
99+
type: true,
100+
branchName: true,
101+
maximumConcurrencyLimit: true,
102+
orgMember: {
103+
select: {
104+
userId: true,
105+
},
106+
},
107+
},
108+
where: {
109+
projectId,
110+
archivedAt: null,
111+
},
112+
});
113+
114+
// Get current concurrency for each environment
115+
const concurrencyLimits: ConcurrencyLimitInfo[] = [];
116+
for (const environment of environments) {
117+
// Skip dev environments that belong to other users
118+
if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) {
119+
continue;
120+
}
121+
122+
const planLimit = currentPlan
123+
? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan) ??
124+
env.DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT
125+
: env.DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT;
126+
127+
// Get current concurrency from Redis
128+
let currentUsage = 0;
129+
try {
130+
currentUsage = await engine.runQueue.currentConcurrencyOfEnvironment({
131+
id: environment.id,
132+
type: environment.type,
133+
organizationId,
134+
projectId,
135+
});
136+
} catch (e) {
137+
// Redis might not be available, default to 0
138+
}
139+
140+
// Determine source
141+
let source: "default" | "plan" | "override" = "default";
142+
if (environment.maximumConcurrencyLimit !== planLimit) {
143+
source = "override";
144+
} else if (currentPlan?.v3Subscription?.plan) {
145+
source = "plan";
146+
}
147+
148+
concurrencyLimits.push({
149+
environmentId: environment.id,
150+
environmentType: environment.type,
151+
branchName: environment.branchName,
152+
limit: environment.maximumConcurrencyLimit,
153+
currentUsage,
154+
planLimit,
155+
source,
156+
});
157+
}
158+
159+
// Sort environments
160+
const sortedConcurrencyLimits = sortEnvironments(concurrencyLimits, [
161+
"PRODUCTION",
162+
"STAGING",
163+
"PREVIEW",
164+
"DEVELOPMENT",
165+
]);
166+
167+
// Resolve API rate limit config
168+
const apiRateLimitConfig = resolveApiRateLimitConfig(organization.apiRateLimiterConfig);
169+
const apiRateLimitSource = organization.apiRateLimiterConfig ? "override" : "default";
170+
171+
// Resolve batch rate limit config
172+
const batchRateLimitConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
173+
const batchRateLimitSource = organization.batchRateLimitConfig ? "override" : "default";
174+
175+
// Resolve batch concurrency config
176+
const batchConcurrencyConfig = resolveBatchConcurrencyConfig(
177+
organization.batchQueueConcurrencyConfig
178+
);
179+
const batchConcurrencySource = organization.batchQueueConcurrencyConfig
180+
? "override"
181+
: "default";
182+
183+
// Get schedule count for this org
184+
const scheduleCount = await this._replica.taskSchedule.count({
185+
where: {
186+
instances: {
187+
some: {
188+
environment: {
189+
organizationId,
190+
},
191+
},
192+
},
193+
},
194+
});
195+
196+
// Get plan-level schedule limit
197+
const schedulesLimit = currentPlan?.v3Subscription?.plan?.limits?.schedules?.number ?? null;
198+
199+
return {
200+
rateLimits: {
201+
api: {
202+
name: "API Rate Limit",
203+
description: "Rate limit for API requests (trigger, batch, etc.)",
204+
config: apiRateLimitConfig,
205+
source: apiRateLimitSource,
206+
},
207+
batch: {
208+
name: "Batch Rate Limit",
209+
description: "Rate limit for batch trigger operations",
210+
config: batchRateLimitConfig,
211+
source: batchRateLimitSource,
212+
},
213+
},
214+
concurrencyLimits: sortedConcurrencyLimits,
215+
quotas: {
216+
projects: {
217+
name: "Projects",
218+
description: "Maximum number of projects in this organization",
219+
limit: organization.maximumProjectCount,
220+
currentUsage: organization._count.projects,
221+
source: "default",
222+
},
223+
schedules:
224+
schedulesLimit !== null
225+
? {
226+
name: "Schedules",
227+
description: "Maximum number of schedules across all projects",
228+
limit: schedulesLimit,
229+
currentUsage: scheduleCount,
230+
source: "plan",
231+
}
232+
: null,
233+
devQueueSize: {
234+
name: "Dev Queue Size",
235+
description: "Maximum pending runs in development environments",
236+
limit: organization.maximumDevQueueSize ?? null,
237+
currentUsage: 0, // Would need to query Redis for this
238+
source: organization.maximumDevQueueSize ? "override" : "default",
239+
},
240+
deployedQueueSize: {
241+
name: "Deployed Queue Size",
242+
description: "Maximum pending runs in deployed environments",
243+
limit: organization.maximumDeployedQueueSize ?? null,
244+
currentUsage: 0, // Would need to query Redis for this
245+
source: organization.maximumDeployedQueueSize ? "override" : "default",
246+
},
247+
},
248+
batchConcurrency: {
249+
limit: batchConcurrencyConfig.processingConcurrency,
250+
source: batchConcurrencySource,
251+
},
252+
planName: currentPlan?.v3Subscription?.plan?.title ?? null,
253+
};
254+
}
255+
}
256+
257+
function resolveApiRateLimitConfig(apiRateLimiterConfig?: unknown): RateLimiterConfig {
258+
const defaultConfig: RateLimitTokenBucketConfig = {
259+
type: "tokenBucket",
260+
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
261+
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
262+
maxTokens: env.API_RATE_LIMIT_MAX,
263+
};
264+
265+
if (!apiRateLimiterConfig) {
266+
return defaultConfig;
267+
}
268+
269+
const parsed = RateLimiterConfig.safeParse(apiRateLimiterConfig);
270+
if (!parsed.success) {
271+
return defaultConfig;
272+
}
273+
274+
return parsed.data;
275+
}
276+
277+
function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig {
278+
const defaultConfig: RateLimitTokenBucketConfig = {
279+
type: "tokenBucket",
280+
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
281+
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
282+
maxTokens: env.BATCH_RATE_LIMIT_MAX,
283+
};
284+
285+
if (!batchRateLimitConfig) {
286+
return defaultConfig;
287+
}
288+
289+
const parsed = RateLimiterConfig.safeParse(batchRateLimitConfig);
290+
if (!parsed.success) {
291+
return defaultConfig;
292+
}
293+
294+
return parsed.data;
295+
}
296+
297+
function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): {
298+
processingConcurrency: number;
299+
} {
300+
const defaultConfig = {
301+
processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
302+
};
303+
304+
if (!batchConcurrencyConfig) {
305+
return defaultConfig;
306+
}
307+
308+
if (typeof batchConcurrencyConfig === "object" && batchConcurrencyConfig !== null) {
309+
const config = batchConcurrencyConfig as Record<string, unknown>;
310+
if (typeof config.processingConcurrency === "number") {
311+
return { processingConcurrency: config.processingConcurrency };
312+
}
313+
}
314+
315+
return defaultConfig;
316+
}

0 commit comments

Comments
 (0)