1- import { conform , useForm } from "@conform-to/react" ;
1+ import { conform , useFieldList , useForm } from "@conform-to/react" ;
22import { 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" ;
49import { 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" ;
618import { json , type ActionFunctionArgs , type LoaderFunctionArgs } from "@remix-run/server-runtime" ;
719import { tryCatch } from "@trigger.dev/core" ;
8- import { useState } from "react" ;
20+ import { useEffect , useState } from "react" ;
921import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
1022import { z } from "zod" ;
1123import { AdminDebugTooltip } from "~/components/admin/debugTooltip" ;
@@ -54,6 +66,8 @@ import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.se
5466import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route" ;
5567import { SpinnerWhite } from "~/components/primitives/Spinner" ;
5668import { cn } from "~/utils/cn" ;
69+ import { logger } from "~/services/logger.server" ;
70+ import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server" ;
5771
5872export 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
107133export 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
211265function 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