886 lines
33 KiB
TypeScript
886 lines
33 KiB
TypeScript
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
|
import { parseWithZod } from "@conform-to/zod";
|
|
import {
|
|
ArrowDownIcon,
|
|
EnvelopeIcon,
|
|
ExclamationTriangleIcon,
|
|
InformationCircleIcon,
|
|
} from "@heroicons/react/20/solid";
|
|
import { DialogClose } from "@radix-ui/react-dialog";
|
|
import {
|
|
Form,
|
|
useActionData,
|
|
useNavigation,
|
|
useSearchParams,
|
|
type MetaFunction,
|
|
} from "@remix-run/react";
|
|
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
|
import { tryCatch } from "@trigger.dev/core";
|
|
import { useEffect, useState } from "react";
|
|
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
|
import simplur from "simplur";
|
|
import { z } from "zod";
|
|
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
|
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
|
import { Feedback } from "~/components/Feedback";
|
|
import {
|
|
MainHorizontallyCenteredContainer,
|
|
PageBody,
|
|
PageContainer,
|
|
} from "~/components/layout/AppLayout";
|
|
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
|
import { Fieldset } from "~/components/primitives/Fieldset";
|
|
import { FormButtons } from "~/components/primitives/FormButtons";
|
|
import { FormError } from "~/components/primitives/FormError";
|
|
import { Header2, Header3 } from "~/components/primitives/Headers";
|
|
import { Input } from "~/components/primitives/Input";
|
|
import { InputGroup } from "~/components/primitives/InputGroup";
|
|
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
|
|
import { Label } from "~/components/primitives/Label";
|
|
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
|
import { Paragraph } from "~/components/primitives/Paragraph";
|
|
import * as Property from "~/components/primitives/PropertyTable";
|
|
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHeader,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
} from "~/components/primitives/Table";
|
|
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
|
import { useFeatures } from "~/hooks/useFeatures";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
|
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
|
import { findProjectBySlug } from "~/models/project.server";
|
|
import {
|
|
ManageConcurrencyPresenter,
|
|
type ConcurrencyResult,
|
|
type EnvironmentWithConcurrency,
|
|
} from "~/presenters/v3/ManageConcurrencyPresenter.server";
|
|
import {
|
|
getCurrentPlan,
|
|
getPlans,
|
|
getSelfServePurchaseBlockReason,
|
|
} from "~/services/platform.v3.server";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { cn } from "~/utils/cn";
|
|
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
|
import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
|
import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
|
|
import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
|
|
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
|
|
|
export const meta: MetaFunction = () => {
|
|
return [
|
|
{
|
|
title: `Manage concurrency | Trigger.dev`,
|
|
},
|
|
];
|
|
};
|
|
|
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
|
const userId = await requireUserId(request);
|
|
const {
|
|
organizationSlug,
|
|
projectParam,
|
|
envParam: _envParam,
|
|
} = EnvironmentParamSchema.parse(params);
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
if (!project) {
|
|
throw new Response(undefined, {
|
|
status: 404,
|
|
statusText: "Project not found",
|
|
});
|
|
}
|
|
|
|
const presenter = new ManageConcurrencyPresenter();
|
|
const [error, result] = await tryCatch(
|
|
presenter.call({
|
|
userId: userId,
|
|
projectId: project.id,
|
|
organizationId: project.organizationId,
|
|
})
|
|
);
|
|
|
|
if (error) {
|
|
throw new Response(undefined, {
|
|
status: 400,
|
|
statusText: error.message,
|
|
});
|
|
}
|
|
|
|
const plans = await tryCatch(getPlans());
|
|
if (!plans) {
|
|
throw new Response(null, { status: 404, statusText: "Plans not found" });
|
|
}
|
|
|
|
return typedjson(result);
|
|
};
|
|
|
|
const FormSchema = z.discriminatedUnion("action", [
|
|
z.object({
|
|
action: z.enum(["purchase"]),
|
|
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
|
}),
|
|
z.object({
|
|
action: z.enum(["quota-increase"]),
|
|
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
|
|
}),
|
|
z.object({
|
|
action: z.enum(["allocate"]),
|
|
// It will only update environments that are passed in
|
|
environments: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
|
})
|
|
),
|
|
}),
|
|
]);
|
|
|
|
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|
const userId = await requireUserId(request);
|
|
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
const redirectPath = concurrencyPath(
|
|
{ slug: organizationSlug },
|
|
{ slug: projectParam },
|
|
{ slug: envParam }
|
|
);
|
|
|
|
if (!project) {
|
|
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const submission = parseWithZod(formData, { schema: FormSchema });
|
|
|
|
if (submission.status !== "success") {
|
|
return json(submission.reply());
|
|
}
|
|
|
|
if (submission.value.action === "allocate") {
|
|
const allocate = new AllocateConcurrencyService();
|
|
const [error, result] = await tryCatch(
|
|
allocate.call({
|
|
userId,
|
|
projectId: project.id,
|
|
organizationId: project.organizationId,
|
|
environments: submission.value.environments,
|
|
})
|
|
);
|
|
|
|
if (error) {
|
|
return json(
|
|
submission.reply({
|
|
fieldErrors: {
|
|
environments: [error instanceof Error ? error.message : "Unknown error"],
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
if (!result.success) {
|
|
return json(submission.reply({ fieldErrors: { environments: [result.error] } }));
|
|
}
|
|
|
|
return redirectWithSuccessMessage(
|
|
`${redirectPath}?success=true`,
|
|
request,
|
|
"Concurrency allocated successfully"
|
|
);
|
|
}
|
|
|
|
const currentPlan = await getCurrentPlan(project.organizationId);
|
|
const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan);
|
|
if (purchaseBlockReason === "plan_unavailable") {
|
|
return json(
|
|
submission.reply({
|
|
fieldErrors: { amount: ["Unable to verify billing status. Please try again."] },
|
|
}),
|
|
{ status: 503 }
|
|
);
|
|
}
|
|
if (purchaseBlockReason === "managed_billing") {
|
|
return json(
|
|
submission.reply({ fieldErrors: { amount: ["Contact us to request more concurrency."] } }),
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const service = new SetConcurrencyAddOnService();
|
|
const [error, result] = await tryCatch(
|
|
service.call({
|
|
userId,
|
|
projectId: project.id,
|
|
organizationId: project.organizationId,
|
|
action: submission.value.action,
|
|
amount: submission.value.amount,
|
|
})
|
|
);
|
|
|
|
if (error) {
|
|
return json(
|
|
submission.reply({
|
|
fieldErrors: { amount: [error instanceof Error ? error.message : "Unknown error"] },
|
|
})
|
|
);
|
|
}
|
|
|
|
if (!result.success) {
|
|
return json(submission.reply({ fieldErrors: { amount: [result.error] } }));
|
|
}
|
|
|
|
return redirectWithSuccessMessage(
|
|
`${redirectPath}?success=true`,
|
|
request,
|
|
submission.value.action === "purchase"
|
|
? "Concurrency updated successfully"
|
|
: "Requested extra concurrency, we'll get back to you soon."
|
|
);
|
|
};
|
|
|
|
export default function Page() {
|
|
const {
|
|
canAddConcurrency,
|
|
extraConcurrency,
|
|
extraAllocatedConcurrency,
|
|
extraUnallocatedConcurrency,
|
|
environments,
|
|
concurrencyPricing,
|
|
maxQuota,
|
|
} = useTypedLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<PageContainer>
|
|
<NavBar>
|
|
<PageTitle title="Concurrency" />
|
|
<PageAccessories>
|
|
<AdminDebugTooltip>
|
|
<Property.Table>
|
|
{environments.map((environment) => (
|
|
<Property.Item key={environment.id}>
|
|
<Property.Label>
|
|
{environment.type}{" "}
|
|
{environment.branchName ? ` (${environment.branchName})` : ""}
|
|
</Property.Label>
|
|
<Property.Value>{environment.id}</Property.Value>
|
|
</Property.Item>
|
|
))}
|
|
</Property.Table>
|
|
</AdminDebugTooltip>
|
|
</PageAccessories>
|
|
</NavBar>
|
|
<PageBody scrollable={true}>
|
|
<MainHorizontallyCenteredContainer>
|
|
{canAddConcurrency ? (
|
|
<Upgradable
|
|
canAddConcurrency={canAddConcurrency}
|
|
extraConcurrency={extraConcurrency}
|
|
extraAllocatedConcurrency={extraAllocatedConcurrency}
|
|
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
|
|
environments={environments}
|
|
concurrencyPricing={concurrencyPricing}
|
|
maxQuota={maxQuota}
|
|
/>
|
|
) : (
|
|
<NotUpgradable environments={environments} />
|
|
)}
|
|
</MainHorizontallyCenteredContainer>
|
|
</PageBody>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
function initialAllocation(environments: ConcurrencyResult["environments"]) {
|
|
return new Map<string, number>(
|
|
environments
|
|
.filter((e) => e.type !== "DEVELOPMENT")
|
|
.map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)])
|
|
);
|
|
}
|
|
|
|
function allocationTotal(environments: ConcurrencyResult["environments"]) {
|
|
const allocation = initialAllocation(environments);
|
|
return Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
|
|
}
|
|
|
|
function Upgradable({
|
|
extraConcurrency,
|
|
extraAllocatedConcurrency,
|
|
extraUnallocatedConcurrency,
|
|
environments,
|
|
concurrencyPricing,
|
|
maxQuota,
|
|
}: ConcurrencyResult) {
|
|
const lastSubmission = useActionData();
|
|
const [form, fields] = useForm({
|
|
id: "allocate-concurrency",
|
|
// TODO: type this
|
|
lastResult: lastSubmission as any,
|
|
onValidate({ formData }) {
|
|
return parseWithZod(formData, { schema: FormSchema });
|
|
},
|
|
shouldRevalidate: "onSubmit",
|
|
});
|
|
const { environments: formEnvironments } = fields;
|
|
|
|
const navigation = useNavigation();
|
|
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
|
|
|
|
const [allocation, setAllocation] = useState(initialAllocation(environments));
|
|
|
|
const allocatedInProject = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
|
|
const initialAllocationInProject = allocationTotal(environments);
|
|
const changeInAllocation = allocatedInProject - initialAllocationInProject;
|
|
const unallocated = extraUnallocatedConcurrency - changeInAllocation;
|
|
const allocationModified = changeInAllocation !== 0;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="border-b border-grid-dimmed pb-1">
|
|
<Header2>Manage your concurrency</Header2>
|
|
</div>
|
|
<Paragraph variant="small">
|
|
Concurrency limits determine how many runs you can execute at the same time. You can add
|
|
extra concurrency to your organization which you can allocate to environments in your
|
|
projects.
|
|
</Paragraph>
|
|
<div className="mt-3 flex flex-col gap-6">
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center first-letter:pb-1">
|
|
<Header3 className="grow">Extra concurrency</Header3>
|
|
<PurchaseConcurrencyModal
|
|
concurrencyPricing={concurrencyPricing}
|
|
extraConcurrency={extraConcurrency}
|
|
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
|
|
maxQuota={maxQuota}
|
|
disabled={unallocated < 0 ? false : allocationModified}
|
|
/>
|
|
</div>
|
|
<Table variant="bright/no-hover">
|
|
<TableBody>
|
|
<TableRow>
|
|
<TableCell className="pl-0 text-text-bright">Extra concurrency purchased</TableCell>
|
|
<TableCell alignment="right" className="tabular-nums text-text-bright">
|
|
{extraConcurrency}
|
|
</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell>Allocated concurrency</TableCell>
|
|
<TableCell alignment="right" className={"tabular-nums text-text-bright"}>
|
|
{allocationModified ? (
|
|
<>
|
|
<span className="text-text-dimmed line-through">
|
|
{extraAllocatedConcurrency}
|
|
</span>{" "}
|
|
{extraAllocatedConcurrency + changeInAllocation}
|
|
</>
|
|
) : (
|
|
extraAllocatedConcurrency
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
<TableRow>
|
|
<TableCell>Unallocated concurrency</TableCell>
|
|
<TableCell
|
|
alignment="right"
|
|
className={cn(
|
|
"tabular-nums",
|
|
unallocated > 0
|
|
? "text-success"
|
|
: unallocated < 0
|
|
? "text-error"
|
|
: "text-text-bright"
|
|
)}
|
|
>
|
|
{allocationModified ? (
|
|
<>
|
|
<span className="text-text-dimmed line-through">
|
|
{extraUnallocatedConcurrency}
|
|
</span>{" "}
|
|
{extraUnallocatedConcurrency - changeInAllocation}
|
|
</>
|
|
) : (
|
|
extraUnallocatedConcurrency
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
<TableRow
|
|
className={
|
|
allocationModified || unallocated > 0 ? undefined : "after:bg-transparent"
|
|
}
|
|
>
|
|
<TableCell
|
|
colSpan={2}
|
|
className={cn("py-0", (unallocated > 0 || allocationModified) && "pr-0")}
|
|
>
|
|
<div className="flex h-10 items-center">
|
|
{allocationModified ? (
|
|
unallocated < 0 ? (
|
|
<div className="flex items-center gap-1">
|
|
<ExclamationTriangleIcon className="size-4 text-error" />
|
|
<span className="text-error">
|
|
You're trying to allocate more concurrency than your total purchased
|
|
amount.
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex w-full items-center justify-between gap-3">
|
|
<div className="flex items-center gap-1">
|
|
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
|
<span>
|
|
Save your changes or{" "}
|
|
<button
|
|
className="inline text-indigo-500 hover:text-indigo-300"
|
|
onClick={() => {
|
|
setAllocation(initialAllocation(environments));
|
|
}}
|
|
>
|
|
reset
|
|
</button>
|
|
.
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="primary/small"
|
|
type="submit"
|
|
form="allocate"
|
|
disabled={unallocated < 0 || isLoading}
|
|
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
|
>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
)
|
|
) : unallocated > 0 ? (
|
|
<div className="flex h-full w-full items-center justify-between bg-success/10 px-2.5">
|
|
<div className="flex items-center justify-start gap-1">
|
|
<InformationCircleIcon className="size-4 text-success" />
|
|
<span className="text-success">
|
|
You have {unallocated} extra concurrency available to allocate below.
|
|
</span>
|
|
</div>
|
|
<ArrowDownIcon className="size-4 animate-bounce text-success" />
|
|
</div>
|
|
) : (
|
|
<></>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
<FormError id={formEnvironments.id}>{formEnvironments.errors}</FormError>
|
|
</div>
|
|
<Form className="flex flex-col gap-2" method="post" {...getFormProps(form)} id="allocate">
|
|
<input type="hidden" name="action" value="allocate" />
|
|
<div className="flex items-center pb-1">
|
|
<Header3 className="grow">Concurrency allocation</Header3>
|
|
</div>
|
|
<Table variant="bright/no-hover">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
|
<TableHeaderCell alignment="right">
|
|
<span className="flex items-center justify-end gap-x-1">
|
|
Included{" "}
|
|
<InfoIconTooltip content="This is the included concurrency based on your plan." />
|
|
</span>
|
|
</TableHeaderCell>
|
|
<TableHeaderCell alignment="right">Extra concurrency</TableHeaderCell>
|
|
<TableHeaderCell alignment="right">Total</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{environments.map((environment, index) => (
|
|
<TableRow key={environment.id}>
|
|
<TableCell>
|
|
<EnvironmentCombo
|
|
environment={environment}
|
|
className="max-w-[18ch]"
|
|
tooltipSideOffset={6}
|
|
tooltipSide="top"
|
|
/>
|
|
</TableCell>
|
|
<TableCell alignment="right">{environment.planConcurrencyLimit}</TableCell>
|
|
<TableCell alignment="right">
|
|
<div className="flex items-center justify-end">
|
|
{environment.type === "DEVELOPMENT" ? (
|
|
Math.max(
|
|
0,
|
|
environment.maximumConcurrencyLimit - environment.planConcurrencyLimit
|
|
)
|
|
) : (
|
|
<>
|
|
<input
|
|
type="hidden"
|
|
name={`environments[${index}].id`}
|
|
value={environment.id}
|
|
/>
|
|
<Input
|
|
name={`environments[${index}].amount`}
|
|
type="number"
|
|
variant="outline/small"
|
|
className="text-right"
|
|
containerClassName="w-16"
|
|
fullWidth={false}
|
|
value={allocation.get(environment.id)}
|
|
onChange={(e) => {
|
|
const value = e.target.value === "" ? 0 : Number(e.target.value);
|
|
setAllocation(new Map(allocation).set(environment.id, value));
|
|
}}
|
|
min={0}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell alignment="right">
|
|
{environment.planConcurrencyLimit + (allocation.get(environment.id) ?? 0)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) {
|
|
const { isManagedCloud } = useFeatures();
|
|
const plan = useCurrentPlan();
|
|
const organization = useOrganization();
|
|
const showSelfServe = useShowSelfServe();
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="border-b border-grid-dimmed pb-1">
|
|
<Header2>Your concurrency</Header2>
|
|
</div>
|
|
{isManagedCloud ? (
|
|
<>
|
|
<Paragraph variant="small">
|
|
Concurrency limits determine how many runs you can execute at the same time. You can
|
|
upgrade your plan to get more concurrency. You are currently on the{" "}
|
|
{plan?.v3Subscription?.plan?.title ?? "Free"} plan.
|
|
</Paragraph>
|
|
{showSelfServe ? (
|
|
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
|
Upgrade for more concurrency
|
|
</LinkButton>
|
|
) : (
|
|
<Feedback
|
|
defaultValue="enterprise"
|
|
button={<Button variant="secondary/small">Contact us</Button>}
|
|
/>
|
|
)}
|
|
</>
|
|
) : null}
|
|
<div className="mt-3 flex flex-col gap-3">
|
|
<Table variant="bright/no-hover">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
|
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{environments.map((environment) => (
|
|
<TableRow key={environment.id}>
|
|
<TableCell className="pl-0">
|
|
<EnvironmentCombo environment={environment} />
|
|
</TableCell>
|
|
<TableCell alignment="right">{environment.maximumConcurrencyLimit}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PurchaseConcurrencyModal({
|
|
concurrencyPricing,
|
|
extraConcurrency,
|
|
extraUnallocatedConcurrency,
|
|
maxQuota,
|
|
disabled,
|
|
}: {
|
|
concurrencyPricing: {
|
|
stepSize: number;
|
|
centsPerStep: number;
|
|
};
|
|
extraConcurrency: number;
|
|
extraUnallocatedConcurrency: number;
|
|
maxQuota: number;
|
|
disabled: boolean;
|
|
}) {
|
|
const showSelfServe = useShowSelfServe();
|
|
const lastSubmission = useActionData();
|
|
const [form, fields] = useForm({
|
|
id: "purchase-concurrency",
|
|
// TODO: type this
|
|
lastResult: lastSubmission as any,
|
|
onValidate({ formData }) {
|
|
return parseWithZod(formData, { schema: FormSchema });
|
|
},
|
|
shouldRevalidate: "onSubmit",
|
|
});
|
|
const { amount } = fields;
|
|
|
|
const [amountValue, setAmountValue] = useState(extraConcurrency);
|
|
const navigation = useNavigation();
|
|
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
|
|
|
|
// Close the panel, when we've succeeded
|
|
// This is required because a redirect to the same path doesn't clear state
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [open, setOpen] = useState(false);
|
|
useEffect(() => {
|
|
const success = searchParams.get("success");
|
|
if (success) {
|
|
setOpen(false);
|
|
setSearchParams((s) => {
|
|
s.delete("success");
|
|
return s;
|
|
});
|
|
}
|
|
}, [searchParams.get("success")]);
|
|
|
|
const state = updateState({
|
|
value: amountValue,
|
|
existingValue: extraConcurrency,
|
|
quota: maxQuota,
|
|
extraUnallocatedConcurrency,
|
|
});
|
|
const changeClassName =
|
|
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
|
|
|
|
const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency";
|
|
|
|
if (!showSelfServe) {
|
|
return (
|
|
<Feedback
|
|
defaultValue="enterprise"
|
|
button={<Button variant="secondary/small">Request more</Button>}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="primary/small"
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
{title}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>{title}</DialogHeader>
|
|
<Form method="post" {...getFormProps(form)}>
|
|
<div className="flex flex-col gap-4 pt-2">
|
|
<Paragraph variant="base/bright" spacing>
|
|
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "}
|
|
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. Or you can
|
|
remove any extra concurrency after you have unallocated it from your environments
|
|
first.
|
|
</Paragraph>
|
|
<Fieldset>
|
|
<InputGroup fullWidth>
|
|
<Label htmlFor="amount" className="text-text-dimmed">
|
|
Total extra concurrency
|
|
</Label>
|
|
<InputNumberStepper
|
|
{...getInputProps(amount, { type: "number" })}
|
|
step={concurrencyPricing.stepSize}
|
|
min={0}
|
|
max={undefined}
|
|
value={amountValue}
|
|
onChange={(e) => setAmountValue(Number(e.target.value))}
|
|
disabled={isLoading}
|
|
/>
|
|
<FormError id={amount.errorId}>{amount.errors}</FormError>
|
|
<FormError>{form.errors}</FormError>
|
|
</InputGroup>
|
|
</Fieldset>
|
|
{state === "need_to_increase_unallocated" ? (
|
|
<div className="flex flex-col pb-3">
|
|
<Paragraph variant="small" className="text-warning" spacing>
|
|
You need to unallocate{" "}
|
|
{formatNumber(extraConcurrency - amountValue - extraUnallocatedConcurrency)} more
|
|
concurrency from your environments in order to remove{" "}
|
|
{formatNumber(extraConcurrency - amountValue)} concurrency from your account.
|
|
</Paragraph>
|
|
</div>
|
|
) : state === "above_quota" ? (
|
|
<div className="flex flex-col pb-3">
|
|
<Paragraph variant="small" className="text-warning" spacing>
|
|
Currently you can only have up to {maxQuota} extra concurrency. Send a request
|
|
below to lift your current limit. We'll get back to you soon.
|
|
</Paragraph>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col pb-3 tabular-nums">
|
|
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
|
|
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
|
|
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
|
|
</div>
|
|
<div className="grid grid-cols-2 pt-2">
|
|
<Header3 className="pb-0 font-normal text-text-dimmed">
|
|
<span className="text-text-bright">{formatNumber(extraConcurrency)}</span>{" "}
|
|
current total
|
|
</Header3>
|
|
<Header3 className="justify-self-end font-normal text-text-bright">
|
|
{formatCurrency(
|
|
(extraConcurrency * concurrencyPricing.centsPerStep) /
|
|
concurrencyPricing.stepSize /
|
|
100,
|
|
true
|
|
)}
|
|
</Header3>
|
|
</div>
|
|
<div className="grid grid-cols-2 text-xs">
|
|
<span className="text-text-dimmed">
|
|
({simplur`${extraConcurrency / concurrencyPricing.stepSize} bundle[|s]`})
|
|
</span>
|
|
<span className="justify-self-end text-text-dimmed">/mth</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 pt-2">
|
|
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
|
|
{state === "increase" ? "+" : null}
|
|
{formatNumber(amountValue - extraConcurrency)}
|
|
</Header3>
|
|
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
|
|
{state === "increase" ? "+" : null}
|
|
{formatCurrency(
|
|
((amountValue - extraConcurrency) * concurrencyPricing.centsPerStep) /
|
|
concurrencyPricing.stepSize /
|
|
100,
|
|
true
|
|
)}
|
|
</Header3>
|
|
</div>
|
|
<div className="grid grid-cols-2 text-xs">
|
|
<span className="text-text-dimmed">
|
|
(
|
|
{simplur`${
|
|
(amountValue - extraConcurrency) / concurrencyPricing.stepSize
|
|
} bundle[|s]`}{" "}
|
|
@ {formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth)
|
|
</span>
|
|
<span className="justify-self-end text-text-dimmed">/mth</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 pt-2">
|
|
<Header3 className="pb-0 font-normal text-text-dimmed">
|
|
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
|
|
</Header3>
|
|
<Header3 className="justify-self-end font-normal text-text-bright">
|
|
{formatCurrency(
|
|
(amountValue * concurrencyPricing.centsPerStep) /
|
|
concurrencyPricing.stepSize /
|
|
100,
|
|
true
|
|
)}
|
|
</Header3>
|
|
</div>
|
|
<div className="grid grid-cols-2 text-xs">
|
|
<span className="text-text-dimmed">
|
|
({simplur`${amountValue / concurrencyPricing.stepSize} bundle[|s]`})
|
|
</span>
|
|
<span className="justify-self-end text-text-dimmed">/mth</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<FormButtons
|
|
confirmButton={
|
|
state === "above_quota" ? (
|
|
<>
|
|
<input type="hidden" name="action" value="quota-increase" />
|
|
<Button
|
|
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
|
|
variant="primary/medium"
|
|
type="submit"
|
|
disabled={isLoading}
|
|
>
|
|
{`Send request for ${formatNumber(amountValue)}`}
|
|
</Button>
|
|
</>
|
|
) : state === "decrease" || state === "need_to_increase_unallocated" ? (
|
|
<>
|
|
<input type="hidden" name="action" value="purchase" />
|
|
<Button
|
|
variant="danger/medium"
|
|
type="submit"
|
|
disabled={isLoading || state === "need_to_increase_unallocated"}
|
|
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
|
>
|
|
{`Remove ${formatNumber(extraConcurrency - amountValue)} concurrency`}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<input type="hidden" name="action" value="purchase" />
|
|
<Button
|
|
variant="primary/medium"
|
|
type="submit"
|
|
disabled={isLoading || state === "no_change"}
|
|
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
|
>
|
|
{`Purchase ${formatNumber(amountValue - extraConcurrency)} concurrency`}
|
|
</Button>
|
|
</>
|
|
)
|
|
}
|
|
cancelButton={
|
|
<DialogClose asChild>
|
|
<Button variant="tertiary/medium" disabled={isLoading}>
|
|
Cancel
|
|
</Button>
|
|
</DialogClose>
|
|
}
|
|
/>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function updateState({
|
|
value,
|
|
existingValue,
|
|
quota,
|
|
extraUnallocatedConcurrency,
|
|
}: {
|
|
value: number;
|
|
existingValue: number;
|
|
quota: number;
|
|
extraUnallocatedConcurrency: number;
|
|
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_increase_unallocated" {
|
|
if (value === existingValue) return "no_change";
|
|
if (value < existingValue) {
|
|
const difference = existingValue - value;
|
|
if (difference > extraUnallocatedConcurrency) {
|
|
return "need_to_increase_unallocated";
|
|
}
|
|
return "decrease";
|
|
}
|
|
if (value > quota) return "above_quota";
|
|
return "increase";
|
|
}
|