Skip to content

Commit cb00864

Browse files
claude[bot]Trigger.dev RepoOps
authored andcommitted
feat(webapp): manage schedules add-on from the Limits page
The Schedules row on the Limits page now has a "Manage" button that lets you purchase or reduce extra schedules directly, using the same dialog as the scheduled task page. Mono-RevId: 465b118b16c3d669e0a5a8c902e93a4df05e62c9
1 parent 9424281 commit cb00864

4 files changed

Lines changed: 108 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
You can now buy or reduce extra schedules from the Schedules row on the Limits page.

‎apps/webapp/app/presenters/v3/LimitsPresenter.server.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Ratelimit } from "@upstash/ratelimit";
22
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
33
import { env } from "~/env.server";
4-
import { getCurrentPlan } from "~/services/platform.v3.server";
4+
import { type SchedulePricing } from "~/components/schedules/PurchaseSchedulesModal";
5+
import { getCachedPlans, getCurrentPlan } from "~/services/platform.v3.server";
56
import {
67
RateLimiterConfig,
78
createLimiterFromConfig,
@@ -91,6 +92,13 @@ export type LimitsResult = {
9192
planName: string | null;
9293
organizationId: string;
9394
isOnTopPlan: boolean;
95+
/** Set when the plan allows self-serve schedule add-on purchases. */
96+
schedulePurchase: {
97+
schedulePricing: SchedulePricing;
98+
extraSchedules: number;
99+
maxScheduleQuota: number;
100+
planScheduleLimit: number;
101+
} | null;
94102
};
95103

96104
export class LimitsPresenter extends BasePresenter {
@@ -135,6 +143,10 @@ export class LimitsPresenter extends BasePresenter {
135143
const currentPlan = await getCurrentPlan(organizationId);
136144
const limits = currentPlan?.v3Subscription?.plan?.limits;
137145
const isOnTopPlan = currentPlan?.v3Subscription?.plan?.code === "v3_pro_1";
146+
// Same gate as the scheduled task page's purchase button (ScheduleListPresenter).
147+
// Cached because this page polls. Started now and awaited at the end so it overlaps
148+
// with the queries below.
149+
const plansPromise = limits?.schedules?.canExceed === true ? getCachedPlans() : undefined;
138150

139151
// Resolve rate limit configs (org override or default)
140152
const apiRateLimitConfig = resolveApiRateLimitConfig(organization.apiRateLimiterConfig);
@@ -255,8 +267,20 @@ export class LimitsPresenter extends BasePresenter {
255267
const hasStagingEnvironment = limits?.hasStagingEnvironment ?? false;
256268
const supportLevel = limits?.support ?? "community";
257269

270+
const schedulePricing = (await plansPromise)?.addOnPricing.schedules ?? null;
271+
const extraSchedules = currentPlan?.v3Subscription?.addOns?.schedules?.purchased ?? 0;
272+
258273
return {
259274
isOnTopPlan,
275+
schedulePurchase:
276+
schedulePricing && schedulesLimit !== null
277+
? {
278+
schedulePricing,
279+
extraSchedules,
280+
maxScheduleQuota: currentPlan?.v3Subscription?.addOns?.schedules?.quota ?? 0,
281+
planScheduleLimit: schedulesLimit - extraSchedules,
282+
}
283+
: null,
260284
rateLimits: {
261285
api: {
262286
name: "API rate limit",

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
TableRow,
2626
} from "~/components/primitives/Table";
2727
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
28+
import { PurchaseSchedulesModal } from "~/components/schedules/PurchaseSchedulesModal";
2829
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
2930
import { useEnvironment } from "~/hooks/useEnvironment";
3031
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
@@ -39,6 +40,7 @@ import {
3940
type QuotaInfo,
4041
type RateLimitInfo,
4142
} from "~/presenters/v3/LimitsPresenter.server";
43+
import { rbac } from "~/services/rbac.server";
4244
import { requireUser } from "~/services/session.server";
4345
import { hasWebhooksAccess } from "~/v3/webhooksAccess.server";
4446
import { cn } from "~/utils/cn";
@@ -48,6 +50,7 @@ import {
4850
docsPath,
4951
EnvironmentParamSchema,
5052
organizationBillingPath,
53+
v3SchedulesAddOnPath,
5154
} from "~/utils/pathBuilder";
5255
import { limitsAgentPageContext } from "~/components/dashboard-agent/suggested-prompts";
5356
import { WhenAgentUnavailable } from "~/components/dashboard-agent/WhenAgentUnavailable";
@@ -81,6 +84,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8184
});
8285
}
8386

87+
const auth = await rbac.authenticateSession(request, {
88+
userId,
89+
organizationId: project.organizationId,
90+
});
91+
const canManageBilling = auth.ok && auth.ability.can("manage", { type: "billing" });
92+
8493
const presenter = new LimitsPresenter();
8594
const [error, result] = await tryCatch(
8695
presenter.call({
@@ -103,6 +112,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
103112

104113
return typedjson({
105114
...result,
115+
canManageBilling,
106116
autoReloadPollIntervalMs,
107117
});
108118
};
@@ -174,6 +184,8 @@ export default function Page() {
174184
quotas={data.quotas}
175185
isOnTopPlan={data.isOnTopPlan}
176186
billingPath={organizationBillingPath(organization)}
187+
schedulePurchase={data.schedulePurchase}
188+
canManageBilling={data.canManageBilling}
177189
/>
178190

179191
{/* Features Section */}
@@ -517,10 +529,14 @@ function QuotasSection({
517529
quotas,
518530
isOnTopPlan,
519531
billingPath,
532+
schedulePurchase,
533+
canManageBilling,
520534
}: {
521535
quotas: LimitsResult["quotas"];
522536
isOnTopPlan: boolean;
523537
billingPath: string;
538+
schedulePurchase: LimitsResult["schedulePurchase"];
539+
canManageBilling: boolean;
524540
}) {
525541
// Collect all quotas that should be shown
526542
const quotaRows: QuotaInfo[] = [];
@@ -584,6 +600,8 @@ function QuotasSection({
584600
isOnTopPlan={isOnTopPlan}
585601
billingPath={billingPath}
586602
showSelfServe={showSelfServe}
603+
schedulePurchase={schedulePurchase}
604+
canManageBilling={canManageBilling}
587605
/>
588606
))}
589607
</TableBody>
@@ -597,12 +615,18 @@ function QuotaRow({
597615
isOnTopPlan,
598616
billingPath,
599617
showSelfServe,
618+
schedulePurchase,
619+
canManageBilling,
600620
}: {
601621
quota: QuotaInfo;
602622
isOnTopPlan: boolean;
603623
billingPath: string;
604624
showSelfServe: boolean;
625+
schedulePurchase: LimitsResult["schedulePurchase"];
626+
canManageBilling: boolean;
605627
}) {
628+
const organization = useOrganization();
629+
606630
// For log retention and query period, we don't show current usage as it's a duration, not a count
607631
// For widgets per dashboard, the usage varies per dashboard so we don't show a single number
608632
const isDurationQuota =
@@ -675,6 +699,24 @@ function QuotaRow({
675699
);
676700
}
677701

702+
// Same purchase flow as the scheduled task page
703+
if (quota.name === "Schedules" && schedulePurchase) {
704+
return (
705+
<div className="flex justify-end">
706+
<PurchaseSchedulesModal
707+
actionPath={v3SchedulesAddOnPath(organization)}
708+
schedulePricing={schedulePurchase.schedulePricing}
709+
extraSchedules={schedulePurchase.extraSchedules}
710+
usedSchedules={quota.currentUsage}
711+
maxQuota={schedulePurchase.maxScheduleQuota}
712+
planScheduleLimit={schedulePurchase.planScheduleLimit}
713+
canManageBilling={canManageBilling}
714+
triggerButton={<Button variant="secondary/small">Manage</Button>}
715+
/>
716+
</div>
717+
);
718+
}
719+
678720
if (!quota.isUpgradable) {
679721
return null;
680722
}

‎apps/webapp/app/services/platform.v3.server.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,16 @@ function initializePlatformCache() {
208208
fresh: 60_000,
209209
stale: 120_000,
210210
}),
211+
// Own context: getCachedPlans' loader throws on failure, and the default context would
212+
// leave a failed background refresh as an unhandled rejection.
213+
plans: new Namespace<PlansResult>(
214+
{ waitUntil: (p) => void p.catch(() => {}) },
215+
{
216+
stores: [memory, redisCacheStore],
217+
fresh: 60_000,
218+
stale: 120_000,
219+
}
220+
),
211221
});
212222

213223
return cache;
@@ -555,6 +565,31 @@ export async function getPlans() {
555565
}
556566
}
557567

568+
type PlansResult = NonNullable<Awaited<ReturnType<typeof getPlans>>>;
569+
570+
/**
571+
* {@link getPlans} behind a short SWR cache, for pages that poll. The catalog is the same for
572+
* every org, so it is cached under one key. The loader throws on failure because `swr` stores
573+
* whatever it returns, `undefined` included, which would overwrite a good stale catalog. A
574+
* failed background refresh therefore keeps the stale value; a failed cold fetch returns
575+
* `undefined`.
576+
*/
577+
export async function getCachedPlans(): Promise<PlansResult | undefined> {
578+
if (!client) return undefined;
579+
580+
try {
581+
const result = await platformCache.plans.swr("all", async () => {
582+
const plans = await getPlans();
583+
if (!plans) throw new Error("getPlans failed");
584+
return plans;
585+
});
586+
return result.err ? undefined : result.val;
587+
} catch (_e) {
588+
recordPlatformFailure("getPlans", "caught");
589+
return undefined;
590+
}
591+
}
592+
558593
export async function activateFreePlan(
559594
organizationId: string,
560595
userId: string,

0 commit comments

Comments
 (0)