Skip to content

Commit b59385d

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp,run-engine): archive unused queues from the Concurrency page
Archive queues you no longer use from the Concurrency page. Archived queues are hidden from the list and don't count towards the Allocated total, and you can bring them back at any time. Mono-RevId: 6b28883389e5d2b87396688fe9c41a230337cb45
1 parent 321f51b commit b59385d

28 files changed

Lines changed: 2581 additions & 430 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Archive queues you no longer use from the Concurrency page. Archived queues are hidden from the list and no longer count towards allocated concurrency.

‎apps/webapp/app/components/queues/QueueControls.tsx‎

Lines changed: 205 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
1-
import { AdjustmentsHorizontalIcon, PauseIcon, PlayIcon } from "@heroicons/react/20/solid";
1+
import {
2+
AdjustmentsHorizontalIcon,
3+
ArchiveBoxArrowDownIcon,
4+
ArchiveBoxXMarkIcon,
5+
PauseIcon,
6+
PlayIcon,
7+
} from "@heroicons/react/20/solid";
28
import { DialogClose } from "@radix-ui/react-dialog";
3-
import { Form, useNavigation } from "@remix-run/react";
9+
import { Form, useFetcher, useNavigation } from "@remix-run/react";
410
import { useEffect, useState } from "react";
511
import { cn } from "~/utils/cn";
612
import type { QueueLimits } from "~/components/queues/queue-limits";
7-
import { Button, type ButtonVariant } from "~/components/primitives/Buttons";
13+
import { Button, type ButtonVariant, LinkButton } from "~/components/primitives/Buttons";
14+
import { useEnvironment } from "~/hooks/useEnvironment";
15+
import { useOrganization } from "~/hooks/useOrganizations";
16+
import { useProject } from "~/hooks/useProject";
17+
import type { ArchiveCheckResult } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.$queueParam.archive-check";
18+
import {
19+
type EnvironmentForPath,
20+
type OrgForPath,
21+
type ProjectForPath,
22+
v3RunsPath,
23+
} from "~/utils/pathBuilder";
824
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
925
import { FormButtons } from "~/components/primitives/FormButtons";
1026
import { FormError } from "~/components/primitives/FormError";
@@ -24,9 +40,10 @@ import {
2440
} from "~/components/primitives/Tooltip";
2541

2642
// Per-queue action controls. Extracted from the Queues list route so the queue detail page can
27-
// reuse them. Both submit a `<Form method="post">` to the current route, so whichever route renders
28-
// them must handle the `queue-pause` / `queue-resume` / `queue-override` / `queue-remove-override`
29-
// actions (see `handleQueueMutationAction` in `~/models/queueMutation.server`).
43+
// reuse them. They submit a `<Form method="post">` to the current route, so whichever route renders
44+
// them must handle the `queue-pause` / `queue-resume` / `queue-override` / `queue-remove-override` /
45+
// `queue-archive` / `queue-unarchive` actions (see `handleQueueMutationAction` in
46+
// `~/models/queueMutation.server`).
3047

3148
export function QueuePauseResumeButton({
3249
queue,
@@ -594,3 +611,185 @@ export function QueueOverrideConcurrencyButton({
594611
</Dialog>
595612
);
596613
}
614+
615+
function archiveCheckPath(
616+
organization: OrgForPath,
617+
project: ProjectForPath,
618+
environment: EnvironmentForPath,
619+
queueFriendlyId: string
620+
) {
621+
return `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/queues/${queueFriendlyId}/archive-check`;
622+
}
623+
624+
export function QueueArchiveButton({
625+
queue,
626+
trigger = "menu",
627+
disabled = false,
628+
lastOnPage = false,
629+
onDone,
630+
}: {
631+
/** The "id" here is a friendlyId */
632+
queue: { id: string; name: string; type: "task" | "custom"; archivedAt: Date | null };
633+
/** The only row on a later page, so archiving it should go back a page. */
634+
lastOnPage?: boolean;
635+
/** "menu" renders a row-menu item; "button" a standalone button for the detail page. */
636+
trigger?: "menu" | "button";
637+
disabled?: boolean;
638+
/** Called after submitting, e.g. to close the row menu that hosts this control. */
639+
onDone?: () => void;
640+
}) {
641+
const [isOpen, setIsOpen] = useState(false);
642+
const organization = useOrganization();
643+
const project = useProject();
644+
const environment = useEnvironment();
645+
const check = useFetcher<ArchiveCheckResult>();
646+
const noPermission = "You don't have permission to manage queues";
647+
648+
if (queue.archivedAt) {
649+
return (
650+
<Form method="post" onSubmit={() => onDone?.()}>
651+
<input type="hidden" name="action" value="queue-unarchive" />
652+
<input type="hidden" name="friendlyId" value={queue.id} />
653+
{trigger === "button" ? (
654+
<Button
655+
type="submit"
656+
variant="secondary/small"
657+
LeadingIcon={ArchiveBoxXMarkIcon}
658+
disabled={disabled}
659+
tooltip={disabled ? noPermission : undefined}
660+
>
661+
Unarchive
662+
</Button>
663+
) : (
664+
<PopoverMenuItem
665+
type="submit"
666+
icon={ArchiveBoxXMarkIcon}
667+
leadingIconClassName="text-text-dimmed"
668+
title={disabled ? noPermission : "Unarchive"}
669+
disabled={disabled}
670+
/>
671+
)}
672+
</Form>
673+
);
674+
}
675+
676+
const openDialog = (open: boolean) => {
677+
setIsOpen(open);
678+
if (open) {
679+
check.load(archiveCheckPath(organization, project, environment, queue.id));
680+
}
681+
};
682+
683+
const result = check.state === "idle" ? check.data : undefined;
684+
const queueFilterName = queue.type === "task" ? `task/${queue.name}` : queue.name;
685+
686+
return (
687+
<Dialog open={isOpen} onOpenChange={openDialog}>
688+
<DialogTrigger asChild>
689+
{trigger === "button" ? (
690+
<Button
691+
type="button"
692+
variant="secondary/small"
693+
LeadingIcon={ArchiveBoxArrowDownIcon}
694+
disabled={disabled}
695+
tooltip={disabled ? noPermission : undefined}
696+
>
697+
Archive…
698+
</Button>
699+
) : (
700+
<PopoverMenuItem
701+
icon={ArchiveBoxArrowDownIcon}
702+
leadingIconClassName="text-text-dimmed"
703+
title={disabled ? noPermission : "Archive..."}
704+
disabled={disabled}
705+
/>
706+
)}
707+
</DialogTrigger>
708+
<DialogContent>
709+
{result === undefined ? (
710+
<>
711+
<DialogHeader>Archive queue?</DialogHeader>
712+
<div className="flex items-center gap-2 pt-3">
713+
<Spinner className="size-4" />
714+
<Paragraph>Checking whether this queue can be archived…</Paragraph>
715+
</div>
716+
</>
717+
) : result.archivable ? (
718+
<>
719+
<DialogHeader>Archive queue?</DialogHeader>
720+
<div className="flex flex-col gap-3 pt-3">
721+
<Paragraph>
722+
{`This hides the "${queue.name}" queue from this list. It doesn't affect any runs, and its limits and settings are kept.`}
723+
</Paragraph>
724+
<Paragraph>
725+
If a future deploy declares this queue again, it will be unarchived automatically.
726+
You can also find it with the "Show archived" toggle.
727+
</Paragraph>
728+
<Form
729+
method="post"
730+
onSubmit={() => {
731+
setIsOpen(false);
732+
onDone?.();
733+
}}
734+
>
735+
<input type="hidden" name="action" value="queue-archive" />
736+
<input type="hidden" name="friendlyId" value={queue.id} />
737+
{lastOnPage ? <input type="hidden" name="lastOnPage" value="true" /> : null}
738+
<FormButtons
739+
confirmButton={
740+
<Button
741+
type="submit"
742+
shortcut={{ modifiers: ["mod"], key: "enter" }}
743+
variant="primary/medium"
744+
LeadingIcon={ArchiveBoxArrowDownIcon}
745+
>
746+
Archive queue
747+
</Button>
748+
}
749+
cancelButton={
750+
<DialogClose asChild>
751+
<Button type="button" variant="secondary/medium">
752+
Cancel
753+
</Button>
754+
</DialogClose>
755+
}
756+
/>
757+
</Form>
758+
</div>
759+
</>
760+
) : (
761+
<>
762+
<DialogHeader>Can't archive this queue yet</DialogHeader>
763+
<div className="flex flex-col gap-3 pt-3">
764+
<Paragraph>{result.reason}</Paragraph>
765+
<FormButtons
766+
confirmButton={
767+
result.activeRuns ? (
768+
<LinkButton
769+
variant="secondary/medium"
770+
to={v3RunsPath(organization, project, environment, {
771+
queues: [queueFilterName],
772+
statuses: ["PENDING", "DEQUEUED", "EXECUTING"],
773+
period: "30d",
774+
rootOnly: false,
775+
})}
776+
>
777+
View runs
778+
</LinkButton>
779+
) : undefined
780+
}
781+
cancelButton={
782+
<DialogClose asChild>
783+
<Button type="button" variant="secondary/medium">
784+
Close
785+
</Button>
786+
</DialogClose>
787+
}
788+
/>
789+
</div>
790+
</>
791+
)}
792+
</DialogContent>
793+
</Dialog>
794+
);
795+
}

‎apps/webapp/app/models/queueMutation.server.ts‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
22
import { getUserById } from "~/models/user.server";
3+
import { logger } from "~/services/logger.server";
34
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
45
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
56
import {
67
isValidQueueOverridePercent,
78
MAX_QUEUE_OVERRIDE_PERCENT,
89
MIN_QUEUE_OVERRIDE_PERCENT,
910
} from "~/v3/services/concurrencySystem.server";
11+
import { ArchiveQueueService, archiveQueueErrorMessage } from "~/v3/services/archiveQueue.server";
1012
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
13+
import { queueArchivingEnabled } from "~/v3/services/queueArchivingEnabled.server";
1114

1215
/**
13-
* Handles the per-queue mutating form actions (pause/resume/override/remove-override) shared by the
14-
* Queues list route and the queue detail route. Returns a redirect Response for one of those four
15-
* actions, or `null` if `formData`'s `action` isn't one of them (so the caller can fall through to
16+
* Handles the per-queue mutating form actions (pause/resume/override/remove-override/archive/
17+
* unarchive) shared by the Queues list route and the queue detail route. Returns a redirect Response
18+
* for one of those actions, or `null` if `formData`'s `action` isn't one of them (so the caller can fall through to
1619
* its own action handling). `redirectPath` is where to send the user afterwards — the caller passes
1720
* its own page so a mutation from the detail page stays on the detail page.
1821
*/
@@ -22,12 +25,15 @@ export async function handleQueueMutationAction({
2225
userId,
2326
formData,
2427
redirectPath,
28+
archiveSuccessRedirectPath,
2529
}: {
2630
request: Request;
2731
environment: AuthenticatedEnvironment;
2832
userId: string;
2933
formData: FormData;
3034
redirectPath: string;
35+
/** Where a successful archive goes, when the archived row leaves its page empty. */
36+
archiveSuccessRedirectPath?: string;
3137
}): Promise<Response | null> {
3238
const action = formData.get("action");
3339

@@ -281,6 +287,53 @@ export async function handleQueueMutationAction({
281287
noun === "limit" ? "Limit override removed" : "Queue concurrency limit reset"
282288
);
283289
}
290+
case "queue-archive":
291+
case "queue-unarchive": {
292+
const friendlyId = formData.get("friendlyId");
293+
if (!friendlyId) {
294+
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
295+
}
296+
297+
// Unarchiving stays allowed so queues archived before the flag was turned off can come back.
298+
if (
299+
action === "queue-archive" &&
300+
!(await queueArchivingEnabled(environment.organizationId))
301+
) {
302+
return redirectWithErrorMessage(
303+
redirectPath,
304+
request,
305+
"Queue archiving isn't enabled for this organization"
306+
);
307+
}
308+
309+
const service = new ArchiveQueueService();
310+
const result =
311+
action === "queue-archive"
312+
? await service.archive(environment, friendlyId.toString())
313+
: await service.unarchive(environment, friendlyId.toString());
314+
315+
if (result.isErr()) {
316+
if (result.error.type === "other") {
317+
logger.error("Queue archive action failed", {
318+
action,
319+
friendlyId: friendlyId.toString(),
320+
environmentId: environment.id,
321+
error: result.error.cause,
322+
});
323+
}
324+
return redirectWithErrorMessage(
325+
redirectPath,
326+
request,
327+
archiveQueueErrorMessage(result.error)
328+
);
329+
}
330+
331+
return redirectWithSuccessMessage(
332+
action === "queue-archive" ? (archiveSuccessRedirectPath ?? redirectPath) : redirectPath,
333+
request,
334+
action === "queue-archive" ? "Queue archived" : "Queue unarchived"
335+
);
336+
}
284337
default:
285338
return null;
286339
}

‎apps/webapp/app/presenters/v3/QueueAllocationPresenter.server.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import { Prisma } from "@trigger.dev/database";
12
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
23
import { sqlDatabaseSchema } from "~/db.server";
34
import { BasePresenter } from "./basePresenter.server";
45

56
export type QueueAllocation = {
6-
/** Number of V2 queues in the environment. */
7+
/** Number of unarchived V2 queues in the environment. */
78
totalQueues: number;
89
/** Sum of explicit per-queue limits, each clamped to the env limit. */
910
allocated: number;
@@ -16,16 +17,20 @@ export type QueueAllocation = {
1617
*
1718
* The page only needs the aggregate `allocated` value (sum of each queue's
1819
* explicit limit clamped to the env limit), so this computes it in a single
19-
* Postgres aggregate over ALL V2 queues in the environment — no row cap and no
20-
* Redis lookups.
20+
* Postgres aggregate over every unarchived V2 queue in the environment — no row
21+
* cap and no Redis lookups.
2122
*/
2223
export class QueueAllocationPresenter extends BasePresenter {
2324
public async call({
2425
environment,
26+
excludeArchived = true,
2527
}: {
2628
environment: AuthenticatedEnvironment;
29+
/** Off when archiving is disabled for the org, so the tile matches the (unfiltered) list. */
30+
excludeArchived?: boolean;
2731
}): Promise<QueueAllocation> {
2832
const envLimit = environment.maximumConcurrencyLimit;
33+
const archivedFilter = excludeArchived ? Prisma.sql`AND "archivedAt" IS NULL` : Prisma.empty;
2934

3035
const [row] = await this._replica.$queryRaw<
3136
{
@@ -45,6 +50,7 @@ export class QueueAllocationPresenter extends BasePresenter {
4550
FROM ${sqlDatabaseSchema}."TaskQueue"
4651
WHERE "runtimeEnvironmentId" = ${environment.id}
4752
AND "version" = 'V2'
53+
${archivedFilter}
4854
`;
4955

5056
return {

0 commit comments

Comments
 (0)