1078 lines
40 KiB
TypeScript
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"
|
|
/>
|
|
);
|
|
}
|