Skip to content

Commit 4ae0e54

Browse files
committed
Allocating concurrency is working
1 parent 6c0db35 commit 4ae0e54

File tree

2 files changed

+297
-32
lines changed

2 files changed

+297
-32
lines changed

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

Lines changed: 213 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
1-
import { conform, useForm } from "@conform-to/react";
1+
import { conform, useFieldList, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
3-
import { EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid";
3+
import {
4+
EnvelopeIcon,
5+
ExclamationTriangleIcon,
6+
InformationCircleIcon,
7+
PlusIcon,
8+
} from "@heroicons/react/20/solid";
49
import { DialogClose } from "@radix-ui/react-dialog";
5-
import { Form, useActionData, useNavigation, type MetaFunction } from "@remix-run/react";
10+
import {
11+
Form,
12+
useActionData,
13+
useNavigate,
14+
useNavigation,
15+
useSearchParams,
16+
type MetaFunction,
17+
} from "@remix-run/react";
618
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
719
import { tryCatch } from "@trigger.dev/core";
8-
import { useState } from "react";
20+
import { useEffect, useState } from "react";
921
import { typedjson, useTypedLoaderData } from "remix-typedjson";
1022
import { z } from "zod";
1123
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
@@ -54,6 +66,8 @@ import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.se
5466
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
5567
import { SpinnerWhite } from "~/components/primitives/Spinner";
5668
import { cn } from "~/utils/cn";
69+
import { logger } from "~/services/logger.server";
70+
import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
5771

5872
export const meta: MetaFunction = () => {
5973
return [
@@ -99,10 +113,22 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
99113
return typedjson(result);
100114
};
101115

102-
const FormSchema = z.object({
103-
action: z.enum(["purchase", "quota-increase"]),
104-
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
105-
});
116+
const FormSchema = z.discriminatedUnion("action", [
117+
z.object({
118+
action: z.enum(["purchase", "quota-increase"]),
119+
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
120+
}),
121+
z.object({
122+
action: z.enum(["allocate"]),
123+
// It will only update environments that are passed in
124+
environments: z.array(
125+
z.object({
126+
id: z.string(),
127+
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
128+
})
129+
),
130+
}),
131+
]);
106132

107133
export const action = async ({ request, params }: ActionFunctionArgs) => {
108134
const userId = await requireUserId(request);
@@ -126,6 +152,34 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
126152
return json(submission);
127153
}
128154

155+
if (submission.value.action === "allocate") {
156+
const allocate = new AllocateConcurrencyService();
157+
const [error, result] = await tryCatch(
158+
allocate.call({
159+
userId,
160+
projectId: project.id,
161+
organizationId: project.organizationId,
162+
environments: submission.value.environments,
163+
})
164+
);
165+
166+
if (error) {
167+
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
168+
return json(submission);
169+
}
170+
171+
if (!result.success) {
172+
submission.error.amount = [result.error];
173+
return json(submission);
174+
}
175+
176+
return redirectWithSuccessMessage(
177+
`${redirectPath}?success=true`,
178+
request,
179+
"Concurrency allocated successfully"
180+
);
181+
}
182+
129183
const service = new SetConcurrencyAddOnService();
130184
const [error, result] = await tryCatch(
131185
service.call({
@@ -187,7 +241,7 @@ export default function Page() {
187241
</AdminDebugTooltip>
188242
</PageAccessories>
189243
</NavBar>
190-
<PageBody scrollable={false}>
244+
<PageBody scrollable={true}>
191245
<MainHorizontallyCenteredContainer>
192246
{canAddConcurrency ? (
193247
<Upgradable
@@ -209,20 +263,43 @@ export default function Page() {
209263
}
210264

211265
function Upgradable({
212-
canAddConcurrency,
213266
extraConcurrency,
214267
extraAllocatedConcurrency,
215268
extraUnallocatedConcurrency,
216269
environments,
217270
concurrencyPricing,
218271
maxQuota,
219272
}: ConcurrencyResult) {
220-
const organization = useOrganization();
273+
const lastSubmission = useActionData();
274+
const [form, { environments: formEnvironments }] = useForm({
275+
id: "purchase-concurrency",
276+
// TODO: type this
277+
lastSubmission: lastSubmission as any,
278+
onValidate({ formData }) {
279+
return parse(formData, { schema: FormSchema });
280+
},
281+
shouldRevalidate: "onSubmit",
282+
});
283+
284+
const navigation = useNavigation();
285+
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
286+
287+
const [allocation, setAllocation] = useState(
288+
new Map<string, number>(
289+
environments
290+
.filter((e) => e.type !== "DEVELOPMENT")
291+
.map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)])
292+
)
293+
);
294+
295+
const allocated = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
296+
const unallocated = extraConcurrency - allocated;
297+
const allocationModified = allocated !== extraAllocatedConcurrency;
221298

222299
return (
223300
<div className="flex flex-col gap-3">
224301
<div className="border-b border-grid-dimmed pb-1">
225-
<Header2>Your concurrency</Header2>
302+
<Header2>Manage your concurrency</Header2>
226303
</div>
227304
<Paragraph variant="small">
228305
Concurrency limits determine how many runs you can execute at the same time. You can add
@@ -238,6 +315,7 @@ function Upgradable({
238315
extraConcurrency={extraConcurrency}
239316
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
240317
maxQuota={maxQuota}
318+
disabled={allocationModified}
241319
/>
242320
</div>
243321
<Table>
@@ -250,23 +328,83 @@ function Upgradable({
250328
</TableRow>
251329
<TableRow>
252330
<TableCell>Allocated concurrency</TableCell>
253-
<TableCell alignment="right" className="text-text-bright">
254-
{extraAllocatedConcurrency}
331+
<TableCell alignment="right" className={"text-text-bright"}>
332+
{allocationModified ? (
333+
<>
334+
<span className="text-text-dimmed line-through">
335+
{extraAllocatedConcurrency}
336+
</span>{" "}
337+
{allocated}
338+
</>
339+
) : (
340+
allocated
341+
)}
255342
</TableCell>
256343
</TableRow>
257344
<TableRow>
258345
<TableCell>Unallocated concurrency</TableCell>
259346
<TableCell
260347
alignment="right"
261-
className={extraUnallocatedConcurrency > 0 ? "text-success" : "text-text-bright"}
348+
className={
349+
unallocated > 0
350+
? "text-success"
351+
: unallocated < 0
352+
? "text-error"
353+
: "text-text-bright"
354+
}
262355
>
263-
{extraUnallocatedConcurrency}
356+
{allocationModified ? (
357+
<>
358+
<span className="text-text-dimmed line-through">
359+
{extraUnallocatedConcurrency}
360+
</span>{" "}
361+
{unallocated}
362+
</>
363+
) : (
364+
unallocated
365+
)}
366+
</TableCell>
367+
</TableRow>
368+
<TableRow className={allocationModified ? undefined : "after:bg-transparent"}>
369+
<TableCell colSpan={2} className="py-0">
370+
<div className="flex h-10 items-center">
371+
{allocationModified ? (
372+
unallocated < 0 ? (
373+
<div className="flex items-center gap-1">
374+
<ExclamationTriangleIcon className="size-4 text-error" />
375+
<span className="text-error">
376+
You're trying to allocate more concurrency than your total purchased
377+
amount.
378+
</span>
379+
</div>
380+
) : (
381+
<div className="flex w-full items-center justify-between gap-3">
382+
<div className="flex items-center gap-1">
383+
<InformationCircleIcon className="size-4 text-text-bright" />
384+
<span>Save your changes or RESET</span>
385+
</div>
386+
<Button
387+
variant="primary/small"
388+
type="submit"
389+
form="allocate"
390+
disabled={unallocated < 0 || isLoading}
391+
LeadingIcon={isLoading ? SpinnerWhite : undefined}
392+
>
393+
Save
394+
</Button>
395+
</div>
396+
)
397+
) : (
398+
<></>
399+
)}
400+
</div>
264401
</TableCell>
265402
</TableRow>
266403
</TableBody>
267404
</Table>
268405
</div>
269-
<div className="flex flex-col gap-2">
406+
<Form className="flex flex-col gap-2" method="post" {...form.props} id="allocate">
407+
<input type="hidden" name="action" value="allocate" />
270408
<div className="flex items-center pb-1">
271409
<Header3 className="grow">Concurrency allocation</Header3>
272410
</div>
@@ -285,34 +423,51 @@ function Upgradable({
285423
</TableRow>
286424
</TableHeader>
287425
<TableBody>
288-
{environments.map((environment) => (
426+
{environments.map((environment, index) => (
289427
<TableRow key={environment.id}>
290428
<TableCell className="pl-0">
291429
<EnvironmentCombo environment={environment} />
292430
</TableCell>
293431
<TableCell alignment="right">{environment.planConcurrencyLimit}</TableCell>
294432
<TableCell alignment="right">
295433
<div className="flex items-center justify-end">
296-
<Input
297-
type="number"
298-
variant="outline/small"
299-
className="text-right"
300-
containerClassName="w-16"
301-
fullWidth={false}
302-
defaultValue={Math.max(
434+
{environment.type === "DEVELOPMENT" ? (
435+
Math.max(
303436
0,
304437
environment.maximumConcurrencyLimit - environment.planConcurrencyLimit
305-
)}
306-
min="0"
307-
/>
438+
)
439+
) : (
440+
<>
441+
<input
442+
type="hidden"
443+
name={`environments[${index}].id`}
444+
value={environment.id}
445+
/>
446+
<Input
447+
name={`environments[${index}].amount`}
448+
type="number"
449+
variant="outline/small"
450+
className="text-right"
451+
containerClassName="w-16"
452+
fullWidth={false}
453+
value={allocation.get(environment.id)}
454+
onChange={(e) => {
455+
const value = e.target.value === "" ? 0 : Number(e.target.value);
456+
setAllocation(new Map(allocation).set(environment.id, value));
457+
}}
458+
min={0}
459+
/>
460+
</>
461+
)}
308462
</div>
309463
</TableCell>
310464
<TableCell alignment="right">{environment.maximumConcurrencyLimit}</TableCell>
311465
</TableRow>
312466
))}
313467
</TableBody>
314468
</Table>
315-
</div>
469+
<FormError id={formEnvironments.errorId}>{formEnvironments.error}</FormError>
470+
</Form>
316471
</div>
317472
</div>
318473
);
@@ -369,6 +524,7 @@ function PurchaseConcurrencyModal({
369524
extraConcurrency,
370525
extraUnallocatedConcurrency,
371526
maxQuota,
527+
disabled,
372528
}: {
373529
concurrencyPricing: {
374530
stepSize: number;
@@ -377,6 +533,7 @@ function PurchaseConcurrencyModal({
377533
extraConcurrency: number;
378534
extraUnallocatedConcurrency: number;
379535
maxQuota: number;
536+
disabled: boolean;
380537
}) {
381538
const lastSubmission = useActionData();
382539
const [form, { amount }] = useForm({
@@ -393,6 +550,21 @@ function PurchaseConcurrencyModal({
393550
const navigation = useNavigation();
394551
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
395552

553+
// Close the panel, when we've succeeded
554+
// This is required because a redirect to the same path doesn't clear state
555+
const [searchParams, setSearchParams] = useSearchParams();
556+
const [open, setOpen] = useState(false);
557+
useEffect(() => {
558+
const success = searchParams.get("success");
559+
if (success) {
560+
setOpen(false);
561+
setSearchParams((s) => {
562+
s.delete("success");
563+
return s;
564+
});
565+
}
566+
}, [searchParams.get("success")]);
567+
396568
const state = updateState({
397569
value: amountValue,
398570
existingValue: extraConcurrency,
@@ -405,9 +577,17 @@ function PurchaseConcurrencyModal({
405577
const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency";
406578

407579
return (
408-
<Dialog>
580+
<Dialog open={open} onOpenChange={setOpen}>
409581
<DialogTrigger asChild>
410-
<Button variant="primary/small">{title}</Button>
582+
<Button
583+
variant="primary/small"
584+
disabled={disabled}
585+
onClick={() => {
586+
setOpen(true);
587+
}}
588+
>
589+
{title}
590+
</Button>
411591
</DialogTrigger>
412592
<DialogContent>
413593
<DialogHeader>{title}</DialogHeader>
@@ -428,6 +608,7 @@ function PurchaseConcurrencyModal({
428608
{...conform.input(amount, { type: "number" })}
429609
step={concurrencyPricing.stepSize}
430610
min={0}
611+
max={undefined}
431612
value={amountValue}
432613
onChange={(e) => setAmountValue(Number(e.target.value))}
433614
disabled={isLoading}
@@ -453,7 +634,7 @@ function PurchaseConcurrencyModal({
453634
</Paragraph>
454635
</div>
455636
) : (
456-
<div className="flex flex-col pb-3">
637+
<div className="flex flex-col pb-3 tabular-nums">
457638
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
458639
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
459640
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>

0 commit comments

Comments
 (0)