Files
2026-07-13 13:32:57 +08:00

1078 lines
40 KiB
TypeScript

import {
AdjustmentsHorizontalIcon,
ArrowUpCircleIcon,
BookOpenIcon,
PauseIcon,
PlayIcon,
RectangleStackIcon,
} from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation, type MetaFunction } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import type { QueueItem } from "@trigger.dev/core/v3/schemas";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { QueuesHasNoTasks } from "~/components/BlankStatePanels";
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BigNumber } from "~/components/metrics/BigNumber";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton, type ButtonVariant } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { SearchInput } from "~/components/primitives/SearchInput";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import {
InfoIconTooltip,
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { QueueName } from "~/components/runs/v3/QueueName";
import { env } from "~/env.server";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getUserById } from "~/models/user.server";
import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server";
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource";
import {
concurrencyPath,
docsPath,
EnvironmentParamSchema,
v3BillingPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
const SearchParamsSchema = z.object({
query: z.string().optional(),
page: z.coerce.number().min(1).default(1),
});
export const meta: MetaFunction = () => {
return [
{
title: `Queues | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const url = new URL(request.url);
const { page, query } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
try {
const queueListPresenter = new QueueListPresenter();
const queues = await queueListPresenter.call({
environment,
query,
page,
});
const environmentQueuePresenter = new EnvironmentQueuePresenter();
const autoReloadPollIntervalMs = env.QUEUES_AUTORELOAD_POLL_INTERVAL_MS;
return typedjson({
...queues,
environment: await environmentQueuePresenter.call(environment),
autoReloadPollIntervalMs,
});
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
if (request.method.toLowerCase() !== "post") {
return redirectWithErrorMessage(
`/orgs/${params.organizationSlug}/projects/${params.projectParam}/env/${params.envParam}/queues`,
request,
"Wrong method"
);
}
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const formData = await request.formData();
const action = formData.get("action");
const url = new URL(request.url);
const redirectPath = `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues${url.search}`;
if (environment.archivedAt) {
return redirectWithErrorMessage(redirectPath, request, "This branch is archived");
}
switch (action) {
case "environment-pause": {
const pauseService = new PauseEnvironmentService();
const result = await pauseService.call(environment, "paused");
if (!result.success) {
return redirectWithErrorMessage(redirectPath, request, result.error);
}
return redirectWithSuccessMessage(redirectPath, request, "Environment paused");
}
case "environment-resume": {
const resumeService = new PauseEnvironmentService();
const result = await resumeService.call(environment, "resumed");
if (!result.success) {
return redirectWithErrorMessage(redirectPath, request, result.error);
}
return redirectWithSuccessMessage(redirectPath, request, "Environment resumed");
}
case "queue-pause":
case "queue-resume": {
const friendlyId = formData.get("friendlyId");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
const queueService = new PauseQueueService();
const result = await queueService.call(
environment,
friendlyId.toString(),
action === "queue-pause" ? "paused" : "resumed"
);
if (!result.success) {
return redirectWithErrorMessage(
redirectPath,
request,
result.error ?? `Failed to ${action === "queue-pause" ? "pause" : "resume"} queue`
);
}
return redirectWithSuccessMessage(
redirectPath,
request,
`Queue ${action === "queue-pause" ? "paused" : "resumed"}`
);
}
case "queue-override": {
const friendlyId = formData.get("friendlyId");
const concurrencyLimit = formData.get("concurrencyLimit");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
if (!concurrencyLimit) {
return redirectWithErrorMessage(redirectPath, request, "Concurrency limit is required");
}
const limitNumber = parseInt(concurrencyLimit.toString(), 10);
if (isNaN(limitNumber) || limitNumber < 0) {
return redirectWithErrorMessage(
redirectPath,
request,
"Concurrency limit must be a valid number"
);
}
const user = await getUserById(userId);
if (!user) {
return redirectWithErrorMessage(redirectPath, request, "User not found");
}
const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit(
environment,
friendlyId.toString(),
limitNumber,
user
);
if (!result.isOk()) {
return redirectWithErrorMessage(
redirectPath,
request,
"Failed to override queue concurrency limit"
);
}
return redirectWithSuccessMessage(
redirectPath,
request,
"Queue concurrency limit overridden"
);
}
case "queue-remove-override": {
const friendlyId = formData.get("friendlyId");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
const result = await concurrencySystem.queues.resetConcurrencyLimit(
environment,
friendlyId.toString()
);
if (!result.isOk()) {
return redirectWithErrorMessage(
redirectPath,
request,
"Failed to reset queue concurrency limit"
);
}
return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset");
}
default:
return redirectWithErrorMessage(redirectPath, request, "Something went wrong");
}
};
export default function Page() {
const {
environment,
queues,
success,
pagination,
code,
totalQueues,
hasFilters,
autoReloadPollIntervalMs,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const env = useEnvironment();
const plan = useCurrentPlan();
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
const limitStatus =
environment.running === environment.concurrencyLimit * environment.burstFactor
? "limit"
: environment.running > environment.concurrencyLimit
? "burst"
: "within";
const limitClassName =
limitStatus === "burst" ? "text-warning" : limitStatus === "limit" ? "text-error" : undefined;
return (
<PageContainer>
<NavBar>
<PageTitle title="Queues" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/queue-concurrency")}
>
Queues docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="grid grid-cols-3 gap-3 p-3">
<BigNumber
title="Queued"
value={environment.queued}
suffix={env.paused ? <span className="text-warning">paused</span> : undefined}
animate
accessory={
<div className="flex items-start gap-1">
{environment.runsEnabled &&
env.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT ? (
<EnvironmentPauseResumeButton env={env} />
) : null}
<LinkButton
variant="secondary/small"
LeadingIcon={RunsIcon}
leadingIconClassName="text-runs"
className="px-2"
to={v3RunsPath(organization, project, env, {
statuses: ["PENDING"],
period: "30d",
rootOnly: false,
})}
tooltip="View queued runs"
/>
</div>
}
valueClassName={env.paused ? "text-warning tabular-nums" : "tabular-nums"}
compactThreshold={1000000}
/>
<BigNumber
title="Running"
value={environment.running}
animate
valueClassName={cn(limitClassName, "tabular-nums")}
suffix={
limitStatus === "burst" ? (
<span className={cn(limitClassName, "flex items-center gap-1")}>
Including {environment.running - environment.concurrencyLimit} burst runs{" "}
<BurstFactorTooltip environment={environment} />
</span>
) : limitStatus === "limit" ? (
"At concurrency limit"
) : undefined
}
accessory={
<LinkButton
variant="secondary/small"
LeadingIcon={RunsIcon}
leadingIconClassName="text-runs"
className="px-2"
to={v3RunsPath(organization, project, env, {
statuses: ["DEQUEUED", "EXECUTING"],
period: "30d",
rootOnly: false,
})}
tooltip="View running runs"
/>
}
compactThreshold={1000000}
/>
<BigNumber
title="Concurrency limit"
value={environment.concurrencyLimit}
animate
valueClassName={limitClassName}
suffix={
environment.burstFactor > 1 ? (
<span className={cn(limitClassName, "flex items-center gap-1")}>
Burst limit {environment.burstFactor * environment.concurrencyLimit}{" "}
<BurstFactorTooltip environment={environment} />
</span>
) : undefined
}
accessory={
plan ? (
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
<LinkButton
to={concurrencyPath(organization, project, env)}
variant="tertiary/small"
LeadingIcon={ConcurrencyIcon}
leadingIconClassName="text-amber-500"
>
Increase limit
</LinkButton>
) : (
<LinkButton
to={v3BillingPath(organization, "Upgrade your plan for more concurrency")}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Increase limit
</LinkButton>
)
) : null
}
/>
</div>
{success ? (
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr] overflow-x-auto">
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-1.5 py-1.5">
<QueueFilters />
<PaginationControls
currentPage={pagination.currentPage}
totalPages={pagination.mode === "unfiltered" ? pagination.totalPages : 1}
hasNextPage={pagination.mode === "filtered" ? pagination.hasMore : undefined}
showPageNumbers={false}
/>
</div>
<Table containerClassName="border-t">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
<TableHeaderCell alignment="right">Running</TableHeaderCell>
<TableHeaderCell alignment="right">Limit</TableHeaderCell>
<TableHeaderCell
alignment="right"
tooltip={
<div className="max-w-xs space-y-2 p-1 text-left">
<div className="space-y-0.5">
<Header3>Environment</Header3>
<Paragraph
variant="small"
className="text-wrap! text-text-dimmed"
spacing
>
This queue is limited by your environment's concurrency limit of{" "}
{environment.concurrencyLimit}.
</Paragraph>
</div>
<div className="space-y-0.5">
<Header3>User</Header3>
<Paragraph
variant="small"
className="text-wrap! text-text-dimmed"
spacing
>
This queue is limited by a concurrency limit set in your code.
</Paragraph>
</div>
<div className="space-y-0.5">
<Header3>Override</Header3>
<Paragraph
variant="small"
className="text-wrap! text-text-dimmed"
spacing
>
This queue's concurrency limit has been manually overridden from the
dashboard or API.
</Paragraph>
</div>
</div>
}
>
Limited by
</TableHeaderCell>
<TableHeaderCell className="w-[1%] pl-32">
<span className="sr-only">Pause/resume</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{queues.length > 0 ? (
queues.map((queue) => {
const limit = queue.concurrencyLimit ?? environment.concurrencyLimit;
const isAtConcurrencyLimit = queue.running >= limit;
const isAtQueueLimit =
environment.queueSizeLimit !== null &&
queue.queued >= environment.queueSizeLimit;
const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${
queue.name
}`;
return (
<TableRow key={queue.name}>
<TableCell>
<span className="flex items-center gap-2">
<QueueName {...queue} />
{queue.concurrency?.overriddenAt ? (
<SimpleTooltip
button={
<Badge variant="extra-small" className="text-text-bright">
Concurrency limit overridden
</Badge>
}
content="This queue's concurrency limit has been manually overridden from the dashboard or API."
className="max-w-xs"
disableHoverableContent
/>
) : null}
{queue.paused ? (
<Badge variant="extra-small" className="text-warning">
Paused
</Badge>
) : null}
{isAtQueueLimit ? (
<Badge variant="extra-small" className="text-error">
At queue limit
</Badge>
) : null}
{isAtConcurrencyLimit ? (
<Badge variant="extra-small" className="text-warning">
At concurrency limit
</Badge>
) : null}
</span>
</TableCell>
<TableCell
alignment="right"
className={cn(
"w-[1%] pl-16 tabular-nums",
queue.paused ? "opacity-50" : undefined,
isAtQueueLimit && "text-error"
)}
>
{queue.queued}
</TableCell>
<TableCell
alignment="right"
className={cn(
"w-[1%] pl-16 tabular-nums",
queue.paused ? "opacity-50" : undefined,
queue.running > 0 && "text-text-bright",
isAtConcurrencyLimit && "text-warning"
)}
>
{queue.running}
</TableCell>
<TableCell
alignment="right"
className={cn(
"w-[1%] pl-16 tabular-nums",
queue.paused ? "opacity-50" : undefined,
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
)}
>
{limit}
</TableCell>
<TableCell
alignment="right"
className={cn(
"w-[1%] pl-16",
queue.paused ? "opacity-50" : undefined,
isAtConcurrencyLimit && "text-warning",
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
)}
>
{queue.concurrency?.overriddenAt ? (
<span className="text-text-bright">Override</span>
) : queue.concurrencyLimit ? (
"User"
) : (
"Environment"
)}
</TableCell>
<TableCellMenu
isSticky
visibleButtons={
queue.paused && <QueuePauseResumeButton queue={queue} />
}
hiddenButtons={
!queue.paused && <QueuePauseResumeButton queue={queue} />
}
popoverContent={
<>
{queue.paused ? (
<QueuePauseResumeButton
queue={queue}
variant="minimal/small"
fullWidth
showTooltip={false}
/>
) : (
<QueuePauseResumeButton
queue={queue}
variant="minimal/small"
fullWidth
showTooltip={false}
/>
)}
<PopoverMenuItem
icon={RunsIcon}
leadingIconClassName="text-runs"
title="View all runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
period: "30d",
rootOnly: false,
})}
/>
<PopoverMenuItem
icon={RectangleStackIcon}
leadingIconClassName="text-queues"
title="View queued runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
statuses: ["PENDING"],
period: "30d",
rootOnly: false,
})}
/>
<PopoverMenuItem
icon={Spinner}
leadingIconClassName="text-queues animate-none"
title="View running runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
statuses: ["DEQUEUED", "EXECUTING"],
period: "30d",
rootOnly: false,
})}
/>
<QueueOverrideConcurrencyButton
queue={queue}
environmentConcurrencyLimit={environment.concurrencyLimit}
/>
</>
}
/>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={7}>
<div className="grid place-items-center py-6 text-text-dimmed">
<Paragraph>
{hasFilters
? "No queues found matching your filters"
: "No queues found"}
</Paragraph>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : (
<div className="grid place-items-center py-6 text-text-dimmed">
{totalQueues === 0 ? (
<div className="pt-12">
<QueuesHasNoTasks />
</div>
) : code === "engine-version" ? (
<EngineVersionUpgradeCallout />
) : (
<Callout variant="error">Something went wrong</Callout>
)}
</div>
)}
</div>
</PageBody>
</PageContainer>
);
}
function EnvironmentPauseResumeButton({
env,
}: {
env: { type: RuntimeEnvironmentType; paused: boolean };
}) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (navigation.state === "loading" || navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
const isLoading = Boolean(
navigation.formData?.get("action") === (env.paused ? "environment-resume" : "environment-pause")
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<div>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogTrigger asChild>
<Button
type="button"
variant="secondary/small"
LeadingIcon={env.paused ? PlayIcon : PauseIcon}
leadingIconClassName={env.paused ? "text-success" : "text-warning"}
>
{env.paused ? "Resume..." : "Pause environment..."}
</Button>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent className={"text-xs"}>
{env.paused
? `Resume processing runs in ${environmentFullTitle(env)}`
: `Pause processing runs in ${environmentFullTitle(env)}`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<DialogContent>
<DialogHeader>{env.paused ? "Resume environment?" : "Pause environment?"}</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
{env.paused
? `This will allow runs to be dequeued in ${environmentFullTitle(env)} again.`
: `This will pause all runs from being dequeued in ${environmentFullTitle(
env
)}. Any executing runs will continue to run.`}
</Paragraph>
<Form method="post" onSubmit={() => setIsOpen(false)}>
<input
type="hidden"
name="action"
value={env.paused ? "environment-resume" : "environment-pause"}
/>
<FormButtons
confirmButton={
<Button
type="submit"
disabled={isLoading}
variant={env.paused ? "primary/medium" : "danger/medium"}
LeadingIcon={
isLoading ? <Spinner color="white" /> : env.paused ? PlayIcon : PauseIcon
}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{env.paused ? "Resume environment" : "Pause environment"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button type="button" variant="tertiary/medium">
Cancel
</Button>
</DialogClose>
}
/>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function QueuePauseResumeButton({
queue,
variant = "tertiary/small",
fullWidth = false,
showTooltip = true,
}: {
/** The "id" here is a friendlyId */
queue: { id: string; name: string; paused: boolean };
variant?: ButtonVariant;
fullWidth?: boolean;
showTooltip?: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const trigger = showTooltip ? (
<div>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogTrigger asChild>
<Button
type="button"
variant={variant}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
fullWidth={fullWidth}
textAlignLeft={fullWidth}
>
{queue.paused ? "Resume..." : "Pause..."}
</Button>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{queue.paused
? `Resume processing runs in queue "${queue.name}"`
: `Pause processing runs in queue "${queue.name}"`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<DialogTrigger asChild>
<PopoverMenuItem
icon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
title={queue.paused ? "Resume..." : "Pause..."}
/>
</DialogTrigger>
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{trigger}
<DialogContent>
<DialogHeader>{queue.paused ? "Resume queue?" : "Pause queue?"}</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
{queue.paused
? `This will allow runs to be dequeued in the "${queue.name}" queue again.`
: `This will pause all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`}
</Paragraph>
<Form method="post" onSubmit={() => setIsOpen(false)}>
<input
type="hidden"
name="action"
value={queue.paused ? "queue-resume" : "queue-pause"}
/>
<input type="hidden" name="friendlyId" value={queue.id} />
<FormButtons
confirmButton={
<Button
type="submit"
shortcut={{ modifiers: ["mod"], key: "enter" }}
variant={queue.paused ? "primary/medium" : "danger/medium"}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
>
{queue.paused ? "Resume queue" : "Pause queue"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button type="button" variant="tertiary/medium">
Cancel
</Button>
</DialogClose>
}
/>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function QueueOverrideConcurrencyButton({
queue,
environmentConcurrencyLimit,
}: {
queue: QueueItem;
environmentConcurrencyLimit: number;
}) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const [concurrencyLimit, setConcurrencyLimit] = useState<string>(
queue.concurrencyLimit?.toString() ?? environmentConcurrencyLimit.toString()
);
const isOverridden = !!queue.concurrency?.overriddenAt;
const currentLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit;
useEffect(() => {
if (navigation.state === "loading" || navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
const isLoading = Boolean(
navigation.formData?.get("action") === "queue-override" ||
navigation.formData?.get("action") === "queue-remove-override"
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<PopoverMenuItem
icon={AdjustmentsHorizontalIcon}
title={isOverridden ? "Edit override…" : "Override limit…"}
/>
</DialogTrigger>
<DialogContent>
<DialogHeader>
{isOverridden ? "Edit concurrency override" : "Override concurrency limit"}
</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
{isOverridden ? (
<Paragraph>
This queue's concurrency limit is currently overridden to {currentLimit}.
{typeof queue.concurrency?.base === "number" &&
` The original limit set in code was ${queue.concurrency.base}.`}{" "}
You can update the override or remove it to restore the{" "}
{typeof queue.concurrency?.base === "number"
? "limit set in code"
: "environment concurrency limit"}
.
</Paragraph>
) : (
<Paragraph>
Override this queue's concurrency limit. The current limit is {currentLimit}, which is
set {queue.concurrencyLimit !== null ? "in code" : "by the environment"}.
</Paragraph>
)}
<Form method="post" onSubmit={() => setIsOpen(false)} className="space-y-3">
<input type="hidden" name="friendlyId" value={queue.id} />
<div className="space-y-2">
<label htmlFor="concurrencyLimit" className="text-sm text-text-bright">
Concurrency limit
</label>
<Input
type="number"
name="concurrencyLimit"
id="concurrencyLimit"
min="0"
max={environmentConcurrencyLimit}
value={concurrencyLimit}
onChange={(e) => setConcurrencyLimit(e.target.value)}
placeholder={currentLimit.toString()}
autoFocus
/>
</div>
<FormButtons
defaultAction={{
name: "action",
value: "queue-override",
disabled: isLoading || !concurrencyLimit,
}}
confirmButton={
<Button
type="submit"
name="action"
value="queue-override"
disabled={isLoading || !concurrencyLimit}
variant="primary/medium"
LeadingIcon={isLoading && <Spinner color="white" />}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isOverridden ? "Update override" : "Override limit"}
</Button>
}
cancelButton={
<div className="flex items-center justify-between gap-2">
{isOverridden && (
<Button
type="submit"
name="action"
value="queue-remove-override"
disabled={isLoading}
variant="danger/medium"
>
Remove override
</Button>
)}
<DialogClose asChild>
<Button type="button" variant="tertiary/medium">
Cancel
</Button>
</DialogClose>
</div>
}
/>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function EngineVersionUpgradeCallout() {
return (
<div className="mt-4 flex max-w-lg flex-col gap-4 rounded-sm border border-grid-bright bg-background-bright px-4">
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed py-4">
<h4 className="text-base text-text-bright">New queues table</h4>
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("upgrade-to-v4")}
variant={"docs/small"}
>
Upgrade guide
</LinkButton>
</div>
<div className="space-y-4 pb-4">
<Paragraph variant="small">
Upgrade to SDK version 4+ to view the new queues table, and be able to pause and resume
individual queues.
</Paragraph>
<img
src={upgradeForQueuesPath}
alt="Upgrade for queues"
className="rounded-sm border border-grid-dimmed"
/>
</div>
</div>
);
}
export function isEnvironmentPauseResumeFormSubmission(
formMethod: string | undefined,
formData: FormData | undefined
) {
if (!formMethod || !formData) {
return false;
}
return (
formMethod.toLowerCase() === "post" &&
(formData.get("action") === "environment-pause" ||
formData.get("action") === "environment-resume")
);
}
export function QueueFilters() {
return <SearchInput placeholder="Search queues…" paramName="query" resetParams={["page"]} />;
}
function BurstFactorTooltip({
environment,
}: {
environment: { burstFactor: number; concurrencyLimit: number };
}) {
return (
<InfoIconTooltip
content={`Your single queue concurrency limit is capped at ${
environment.concurrencyLimit
}, but you can burst up to ${
environment.burstFactor * environment.concurrencyLimit
} when across multiple queues/tasks.`}
contentClassName="max-w-xs"
/>
);
}