diff --git a/.changeset/chilly-plums-pause.md b/.changeset/chilly-plums-pause.md new file mode 100644 index 00000000000..96eb4c317b1 --- /dev/null +++ b/.changeset/chilly-plums-pause.md @@ -0,0 +1,13 @@ +--- +"@trigger.dev/sdk": minor +"@trigger.dev/core": minor +--- + +Concurrency limits can now be paused and resumed, just like queues: `concurrencyLimits.pause(name)` stops every run holding the limit from being dequeued while keeping its configured bounds, and `concurrencyLimits.resume(name)` starts them again. + +```ts +import { concurrencyLimits } from "@trigger.dev/sdk"; + +await concurrencyLimits.pause("openai"); +await concurrencyLimits.resume("openai"); +``` diff --git a/.changeset/deprecate-queue-concurrency-mutations.md b/.changeset/deprecate-queue-concurrency-mutations.md new file mode 100644 index 00000000000..89ee740f832 --- /dev/null +++ b/.changeset/deprecate-queue-concurrency-mutations.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Deprecate `queues.overrideConcurrencyLimit` and `queues.resetConcurrencyLimit`. These operate on the legacy model where a queue carried its own concurrency limit; declare concurrency with the task `concurrency` option and manage it with `concurrencyLimits.override` and `concurrencyLimits.reset` instead. diff --git a/.server-changes/concurrency-allocation-hardening.md b/.server-changes/concurrency-allocation-hardening.md new file mode 100644 index 00000000000..9f08aed27b8 --- /dev/null +++ b/.server-changes/concurrency-allocation-hardening.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Allocating extra concurrency to environments now requires billing permissions, the same as purchasing it. Allocation changes are also applied atomically, so simultaneous edits can no longer exceed your purchased concurrency. diff --git a/.server-changes/session-transcript-inspector.md b/.server-changes/session-transcript-inspector.md new file mode 100644 index 00000000000..fe6dd1e49b5 --- /dev/null +++ b/.server-changes/session-transcript-inspector.md @@ -0,0 +1,5 @@ +--- +area: webapp +type: feature +--- +Download a session’s original saved transcript file from the session inspector. diff --git a/apps/webapp/app/components/queues/QueueControls.tsx b/apps/webapp/app/components/queues/QueueControls.tsx index 2995103c98c..5574da271a8 100644 --- a/apps/webapp/app/components/queues/QueueControls.tsx +++ b/apps/webapp/app/components/queues/QueueControls.tsx @@ -36,6 +36,7 @@ export function QueuePauseResumeButton({ iconOnly = false, withQueueName = false, disabled = false, + noun = "queue", }: { /** The "id" here is a friendlyId */ queue: { id: string; name: string; paused: boolean }; @@ -48,14 +49,21 @@ export function QueuePauseResumeButton({ /** Render the full "Pause/Resume {name} queue" label instead of the short "Pause"/"Resume". */ withQueueName?: boolean; disabled?: boolean; + /** What the row is called in every user-facing string: named concurrency limits pause through + * the same actions but their dialogs must say "limit". */ + noun?: "queue" | "limit"; }) { const [isOpen, setIsOpen] = useState(false); const label = queue.paused - ? `Resumes the "${queue.name}" queue so its runs can be dequeued again.` - : `Pauses all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`; + ? noun === "limit" + ? `Resumes the "${queue.name}" limit so runs holding it can be dequeued again.` + : `Resumes the "${queue.name}" queue so its runs can be dequeued again.` + : noun === "limit" + ? `Pauses all runs holding the "${queue.name}" limit from being dequeued. Any executing runs will continue to run.` + : `Pauses all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`; - const tooltip = disabled ? "You don't have permission to manage queues" : label; + const tooltip = disabled ? `You don't have permission to manage ${noun}s` : label; const trigger = showTooltip ? (
@@ -91,8 +99,8 @@ export function QueuePauseResumeButton({ ? undefined : withQueueName ? queue.paused - ? "Resume this queue…" - : "Pause this queue…" + ? `Resume this ${noun}…` + : `Pause this ${noun}…` : queue.paused ? "Resume" : "Pause"} @@ -113,7 +121,7 @@ export function QueuePauseResumeButton({ leadingIconClassName={queue.paused ? "text-success" : "text-warning"} title={ disabled - ? "You don't have permission to manage queues" + ? `You don't have permission to manage ${noun}s` : queue.paused ? "Resume..." : "Pause..." @@ -127,12 +135,16 @@ export function QueuePauseResumeButton({ {trigger} - {queue.paused ? "Resume queue?" : "Pause queue?"} + {queue.paused ? `Resume ${noun}?` : `Pause ${noun}?`}
{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.`} + ? noun === "limit" + ? `This will allow runs holding the "${queue.name}" limit to be dequeued again.` + : `This will allow runs to be dequeued in the "${queue.name}" queue again.` + : noun === "limit" + ? `This will pause all runs holding the "${queue.name}" limit from being dequeued. Any executing runs will continue to run.` + : `This will pause all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`}
setIsOpen(false)}> + - {queue.paused ? "Resume queue" : "Pause queue"} + {queue.paused ? `Resume ${noun}` : `Pause ${noun}`} } cancelButton={ @@ -172,6 +185,7 @@ export function QueueOverrideConcurrencyButton({ environmentConcurrencyLimit, trigger, disabled = false, + noun = "queue", }: { queue: { id: string; @@ -185,6 +199,9 @@ export function QueueOverrideConcurrencyButton({ * hover tooltip, for compact placements like the detail-page live blocks. */ trigger?: "menu-item" | "button" | "icon"; disabled?: boolean; + /** What the row is called in every user-facing string: named concurrency limits are overridden + * through the same actions but their dialogs must say "limit". */ + noun?: "queue" | "limit"; }) { const navigation = useNavigation(); const [isOpen, setIsOpen] = useState(false); @@ -316,7 +333,7 @@ export function QueueOverrideConcurrencyButton({
- {disabled ? "You don't have permission to manage queues" : iconLabel} + {disabled ? `You don't have permission to manage ${noun}s` : iconLabel} @@ -343,10 +360,10 @@ export function QueueOverrideConcurrencyButton({ {disabled - ? "You don't have permission to manage queues" + ? `You don't have permission to manage ${noun}s` : hasTotal - ? "Override this queue's per-key and total concurrency limits." - : "Give this queue its own concurrency limit instead of the environment default. Set it as a number or a percentage of the environment limit."} + ? `Override this ${noun}'s per-key and total concurrency limits.` + : `Give this ${noun} its own concurrency limit instead of the environment default. Set it as a number or a percentage of the environment limit.`} @@ -356,7 +373,7 @@ export function QueueOverrideConcurrencyButton({ icon={AdjustmentsHorizontalIcon} title={ disabled - ? "You don't have permission to manage queues" + ? `You don't have permission to manage ${noun}s` : isOverridden ? "Edit override…" : "Override limit…" @@ -373,20 +390,20 @@ export function QueueOverrideConcurrencyButton({ {hasTotal ? ( isOverridden ? ( - This queue's limits are currently overridden. Fill a field to change that limit + This {noun}'s limits are currently overridden. Fill a field to change that limit (blank fields stay unchanged), or remove the override to restore the limits set in code. ) : ( - Override this queue's limits. Per key caps each concurrency key's pool, and total + Override this {noun}'s limits. Per key caps each concurrency key's pool, and total caps runs across all keys together. Leave a field blank to keep that limit unchanged. ) ) : isOverridden ? ( - This queue's concurrency limit is currently overridden to {currentLimit}. + This {noun}'s concurrency limit is currently overridden to {currentLimit}. {typeof queue.limits.perKey.base === "number" && ` The original limit set in code was ${queue.limits.perKey.base}.`}{" "} You can update the override or remove it to restore the{" "} @@ -397,12 +414,13 @@ export function QueueOverrideConcurrencyButton({ ) : ( - Override this queue's concurrency limit. The current limit is {currentLimit}, which is - set {queue.limits.perKey.current !== null ? "in code" : "by the environment"}. + Override this {noun}'s concurrency limit. The current limit is {currentLimit}, which + is set {queue.limits.perKey.current !== null ? "in code" : "by the environment"}. )} setIsOpen(false)} className="space-y-3"> + {hasTotal ? ( <> @@ -524,7 +542,7 @@ export function QueueOverrideConcurrencyButton({ {limitOverCap ? `Can't exceed the environment limit of ${environmentConcurrencyLimit}.` - : `The most concurrent runs this queue can use at once. It can't exceed the environment limit of ${environmentConcurrencyLimit}.`} + : `The most concurrent runs this ${noun} can use at once. It can't exceed the environment limit of ${environmentConcurrencyLimit}.`} )} diff --git a/apps/webapp/app/components/sessions/v1/TranscriptDownloadButton.dom.test.ts b/apps/webapp/app/components/sessions/v1/TranscriptDownloadButton.dom.test.ts new file mode 100644 index 00000000000..1a530d943fa --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/TranscriptDownloadButton.dom.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { createServer, type Server, type ServerResponse } from "node:http"; +import { once } from "node:events"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, test } from "vitest"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import { TranscriptDownloadButton } from "./TranscriptDownloadButton"; + +let server: Server | undefined; +let root: Root | undefined; +let container: HTMLDivElement; +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = undefined; + container?.remove(); + if (server) { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server!.close((error) => (error ? reject(error) : resolve())) + ); + server = undefined; + } +}); + +async function serve(handler: (response: ServerResponse) => void) { + server = createServer((_request, response) => handler(response)); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP listener"); + return `http://127.0.0.1:${address.port}/resources/transcript-download`; +} + +async function render(resourcePath: string, initiallyAvailable: boolean) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => + root!.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" }, + createElement( + ShortcutsProvider, + null, + createElement( + "div", + null, + createElement("h1", null, "Session overview"), + createElement(TranscriptDownloadButton, { resourcePath, initiallyAvailable }) + ) + ) + ) + ) + ); +} + +async function settleUntil(condition: () => boolean) { + const deadline = Date.now() + 2000; + while (!condition() && Date.now() < deadline) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + expect(condition()).toBe(true); +} + +test("a seeded transcript uses a native attachment link without fetching the object", async () => { + let requests = 0; + const url = await serve((response) => { + requests++; + response.end(); + }); + await render(url, true); + const link = container.querySelector('a[aria-label="Download transcript"]'); + expect(link?.href).toBe(url); + expect(link?.hasAttribute("download")).toBe(true); + expect(requests).toBe(0); +}); + +test("an HTML proxy error shows availability guidance and Retry recovers", async () => { + let attempts = 0; + const url = await serve((response) => { + if (++attempts === 1) { + response.writeHead(502, { "Content-Type": "text/html" }); + response.end("Bad gateway"); + } else { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ available: true })); + } + }); + await render(url, false); + await settleUntil(() => !!container.querySelector('[role="alert"]')); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + "Could not check transcript availability." + ); + expect(container.textContent).not.toContain("Unexpected token"); + await act(async () => container.querySelector("button")!.click()); + await settleUntil(() => !!container.querySelector("a[download]")); + expect(attempts).toBe(2); +}); + +test("a pending availability request does not block the overview and a missing object stays hidden", async () => { + let pending: ServerResponse | undefined; + const url = await serve((response) => { + pending = response; + }); + await render(url, false); + await settleUntil(() => !!pending); + expect(container.querySelector("h1")?.textContent).toBe("Session overview"); + expect(container.querySelector("a")).toBeNull(); + await act(async () => { + pending!.writeHead(200, { "Content-Type": "application/json" }); + pending!.end(JSON.stringify({ available: false })); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(container.textContent).toBe("Session overview"); +}); diff --git a/apps/webapp/app/components/sessions/v1/TranscriptDownloadButton.tsx b/apps/webapp/app/components/sessions/v1/TranscriptDownloadButton.tsx new file mode 100644 index 00000000000..e6194cca66a --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/TranscriptDownloadButton.tsx @@ -0,0 +1,78 @@ +import { ArrowDownTrayIcon } from "@heroicons/react/20/solid"; +import { useEffect, useState } from "react"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import * as Property from "~/components/primitives/PropertyTable"; + +export function TranscriptDownloadButton({ + resourcePath, + initiallyAvailable, +}: { + resourcePath: string; + initiallyAvailable: boolean; +}) { + const [checkedAvailability, setAvailability] = useState< + "loading" | "available" | "missing" | "error" + >("loading"); + const availability = initiallyAvailable ? "available" : checkedAvailability; + const [retry, setRetry] = useState(0); + + useEffect(() => { + if (initiallyAvailable) return; + const controller = new AbortController(); + fetch(`${resourcePath}?check=1`, { signal: controller.signal }) + .then(async (response) => { + // A proxy may return HTML; all failed checks use the same actionable message. + if (response.redirected || !response.ok) throw new Error("Availability check failed"); + const body: unknown = await response.json(); + if ( + !body || + typeof body !== "object" || + !("available" in body) || + typeof body.available !== "boolean" + ) { + throw new Error("Invalid availability response"); + } + if (!controller.signal.aborted) setAvailability(body.available ? "available" : "missing"); + }) + .catch(() => { + if (!controller.signal.aborted) setAvailability("error"); + }); + return () => controller.abort(); + }, [resourcePath, initiallyAvailable, retry]); + + if (availability === "loading" || availability === "missing") return null; + + return ( + + Transcript + + {availability === "available" ? ( + + Download + + ) : ( +
+ + Could not check transcript availability. + + +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 7f36b519d65..89b47709cad 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1444,6 +1444,10 @@ const EnvironmentSchema = z .default("info"), RUN_ENGINE_TOTAL_CONCURRENCY_LIMITS_ENABLED: z.string().default("1"), RUN_ENGINE_QUEUE_GATES_ENABLED: z.string().default("0"), + RUN_ENGINE_QUEUE_RECONCILE_ENABLED: z.string().default("1"), + RUN_ENGINE_QUEUE_RECONCILE_SCAN_COUNT: z.coerce.number().int().positive().default(100), + RUN_ENGINE_QUEUE_RECONCILE_LOCK_TTL_SECONDS: z.coerce.number().int().positive().default(10), + RUN_ENGINE_QUEUE_RECONCILE_MAX_PASSES_PER_DEQUEUE: z.coerce.number().int().min(0).default(2), RUN_ENGINE_TREAT_PRODUCTION_EXECUTION_STALLS_AS_OOM: z.string().default("0"), RUN_ENGINE_READ_REPLICA_SNAPSHOTS_SINCE_ENABLED: z.string().default("0"), RUN_ENGINE_SNAPSHOTS_SINCE_REPLICA_RETRY_MIN_MS: z.coerce.number().int().default(50), diff --git a/apps/webapp/app/models/queueMutation.server.ts b/apps/webapp/app/models/queueMutation.server.ts index 5cc972a050b..98fe3566827 100644 --- a/apps/webapp/app/models/queueMutation.server.ts +++ b/apps/webapp/app/models/queueMutation.server.ts @@ -35,6 +35,9 @@ export async function handleQueueMutationAction({ case "queue-pause": case "queue-resume": { const friendlyId = formData.get("friendlyId"); + /** Named concurrency limits pause through these same actions; the noun only + * changes the user-facing messages. */ + const noun = formData.get("noun") === "limit" ? "limit" : "queue"; if (!friendlyId) { return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); } @@ -43,25 +46,27 @@ export async function handleQueueMutationAction({ const result = await queueService.call( environment, friendlyId.toString(), - action === "queue-pause" ? "paused" : "resumed" + action === "queue-pause" ? "paused" : "resumed", + { roles: ["QUEUE", "LIMIT"] } ); if (!result.success) { return redirectWithErrorMessage( redirectPath, request, - result.error ?? `Failed to ${action === "queue-pause" ? "pause" : "resume"} queue` + result.error ?? `Failed to ${action === "queue-pause" ? "pause" : "resume"} ${noun}` ); } return redirectWithSuccessMessage( redirectPath, request, - `Queue ${action === "queue-pause" ? "paused" : "resumed"}` + `${noun === "limit" ? "Limit" : "Queue"} ${action === "queue-pause" ? "paused" : "resumed"}` ); } case "queue-override": { const friendlyId = formData.get("friendlyId"); + const noun = formData.get("noun") === "limit" ? "limit" : "queue"; const mode = formData.get("mode") === "percent" ? "percent" @@ -217,18 +222,21 @@ export async function handleQueueMutationAction({ const message = "message" in error && typeof error.message === "string" ? error.message - : "Failed to override queue concurrency limit"; + : noun === "limit" + ? "Failed to override the limit" + : "Failed to override queue concurrency limit"; return redirectWithErrorMessage(redirectPath, request, message); } return redirectWithSuccessMessage( redirectPath, request, - "Queue concurrency limit overridden" + noun === "limit" ? "Limit overridden" : "Queue concurrency limit overridden" ); } case "queue-remove-override": { const friendlyId = formData.get("friendlyId"); + const noun = formData.get("noun") === "limit" ? "limit" : "queue"; if (!friendlyId) { return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); @@ -245,7 +253,9 @@ export async function handleQueueMutationAction({ return redirectWithErrorMessage( redirectPath, request, - "Failed to reset queue concurrency limit" + noun === "limit" + ? "Failed to remove the limit override" + : "Failed to reset queue concurrency limit" ); } @@ -265,7 +275,11 @@ export async function handleQueueMutationAction({ } } - return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset"); + return redirectWithSuccessMessage( + redirectPath, + request, + noun === "limit" ? "Limit override removed" : "Queue concurrency limit reset" + ); } default: return null; diff --git a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts index b05d3ed5082..13c38f6cff5 100644 --- a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts @@ -7,8 +7,10 @@ import { } from "@trigger.dev/core/v3"; import type { QueueLimits } from "~/components/queues/queue-limits"; import { + boundedIn, type PrismaClientOrTransaction, type TaskQueue, + type TaskQueueRole, type User, type TaskQueueType, } from "@trigger.dev/database"; @@ -23,14 +25,17 @@ export type FoundQueue = Prettify< >; /** - * Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService + * Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService. + * Resolves QUEUE rows by default; callers whose operation also applies to named + * concurrency limits (pause/resume) widen `roles` to include LIMIT rows. */ export async function getQueue( prismaClient: PrismaClientOrTransaction, environment: AuthenticatedEnvironment, - queue: RetrieveQueueParam + queue: RetrieveQueueParam, + opts?: { roles?: TaskQueueRole[] } ) { - const role = "QUEUE" as const; + const roles = opts?.roles ?? ["QUEUE" as const]; if (typeof queue === "string") { return joinQueueWithUser( @@ -39,7 +44,7 @@ export async function getQueue( where: { friendlyId: queue, runtimeEnvironmentId: environment.id, - role, + role: { in: boundedIn(roles) }, }, }) ); @@ -53,7 +58,7 @@ export async function getQueue( where: { name: queueName, runtimeEnvironmentId: environment.id, - role, + role: { in: boundedIn(roles) }, }, }) ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 48a31788eb7..a3648bcbfbc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -287,8 +287,7 @@ export default function Page() { Branches docs - - {limits.isAtLimit ? ( + {!hasBranches && limits.isAtLimit && ( - ) : ( - - New branch… - - } - env="preview" - /> )} -
-
- - {autoArchiveAvailable && ( - + {hasBranches && ( +
+ + {autoArchiveAvailable && ( + + )} + +
+ - )} - - -
+ {limits.isAtLimit ? ( + + ) : ( + + New branch… + + } + env="preview" + /> + )} +
+
+ )} {!hasBranches ? ( -
+
{ return json(submission.reply()); } - if (isPaidAddOnPurchase(submission.value.action)) { + if (requiresManageBilling(submission.value.action)) { const auth = await rbac.authenticateSession(request, { userId, organizationId: project.organizationId, }); if (!auth.ok || !auth.ability.can("manage", { type: "billing" })) { + const permissionError = ["You don't have permission to manage billing."]; return json( submission.reply({ - fieldErrors: { amount: ["You don't have permission to manage billing."] }, + fieldErrors: + submission.value.action === "allocate" + ? { environments: permissionError } + : { amount: permissionError }, }), { status: 403 } ); @@ -479,7 +483,12 @@ function Upgradable({ variant="primary/small" type="submit" form="allocate" - disabled={unallocated < 0 || isLoading} + disabled={!canManageBilling || unallocated < 0 || isLoading} + tooltip={ + canManageBilling + ? undefined + : "You don't have permission to manage billing" + } LeadingIcon={isLoading ? SpinnerWhite : undefined} > Save diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx index efd1e7cf5de..6a8a1f5bc0f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx @@ -1090,9 +1090,45 @@ function QueuesWithMetricsView() { /> {isLimit ? ( - - {""} - + + ) + } + hiddenButtons={ + !queue.paused && ( + + ) + } + popoverContent={ + <> + + + + } + /> ) : ( @@ -2160,9 +2200,45 @@ function ClassicQueuesView() { )} {isLimit ? ( - - {""} - + + ) + } + hiddenButtons={ + !queue.paused && ( + + ) + } + popoverContent={ + <> + + + + } + /> ) : ( + {session.currentRun ? ( Current run diff --git a/apps/webapp/app/routes/api.v1.concurrency-limits.$name.pause.ts b/apps/webapp/app/routes/api.v1.concurrency-limits.$name.pause.ts new file mode 100644 index 00000000000..2c0edefd485 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.concurrency-limits.$name.pause.ts @@ -0,0 +1,50 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { concurrencyLimitsSystem } from "~/v3/services/concurrencyLimitsSystemInstance.server"; + +const ParamsSchema = z.object({ + name: z.string(), +}); + +const BodySchema = z.object({ + action: z.enum(["pause", "resume"]), +}); + +const route = createActionApiRoute( + { + params: ParamsSchema, + body: BodySchema, + authorization: { + action: "write", + resource: () => ({ type: "queues" }), + }, + corsStrategy: "all", + }, + async ({ params, body, authentication }) => { + const result = + body.action === "pause" + ? concurrencyLimitsSystem.limits.pause(authentication.environment, params.name) + : concurrencyLimitsSystem.limits.resume(authentication.environment, params.name); + + return result.match( + (limit) => json(limit), + (error) => { + switch (error.type) { + case "limit_not_found": + return json({ error: "Concurrency limit not found" }, { status: 404 }); + case "conflict": + return json( + { error: "The limit changed concurrently; retry the request" }, + { status: 409 } + ); + default: + return json({ error: `Failed to ${body.action} concurrency limit` }, { status: 500 }); + } + } + ); + } +); + +export const action = route.action; +export const loader = route.loader; diff --git a/apps/webapp/app/routes/resources.branches.auto-archive.tsx b/apps/webapp/app/routes/resources.branches.auto-archive.tsx index d44bc623e15..4a0c5b1a829 100644 --- a/apps/webapp/app/routes/resources.branches.auto-archive.tsx +++ b/apps/webapp/app/routes/resources.branches.auto-archive.tsx @@ -1,7 +1,9 @@ import { type ActionFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime"; import { useFetcher } from "@remix-run/react"; -import { useEffect, useRef, useState } from "react"; -import { Cog6ToothIcon } from "@heroicons/react/20/solid"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { motion, useReducedMotion } from "framer-motion"; +import { useMeasure } from "react-use"; +import { Cog6ToothIcon, TrashIcon } from "@heroicons/react/20/solid"; import { prisma } from "~/db.server"; import { requireUserId } from "~/services/session.server"; import { rbac } from "~/services/rbac.server"; @@ -12,15 +14,29 @@ import { } from "~/services/previewAutoArchive.server"; import { PreviewAutoArchivePolicy } from "~/utils/previewAutoArchive"; import { Button } from "~/components/primitives/Buttons"; -import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/primitives/Dialog"; import { Input } from "~/components/primitives/Input"; -import { TextArea } from "~/components/primitives/TextArea"; -import { Label } from "~/components/primitives/Label"; +import { Checkbox } from "~/components/primitives/Checkbox"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Hint } from "~/components/primitives/Hint"; +import { Label, labelVariants } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { FormError } from "~/components/primitives/FormError"; import { Switch } from "~/components/primitives/Switch"; +import { Spinner } from "~/components/primitives/Spinner"; import { readBoundedBodyText } from "~/utils/boundedRequestBody.server"; +import { trail } from "agentcrumbs"; // @crumbs +const crumb = trail("webapp"); // @crumbs + export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request); if (request.headers.get("content-type")?.split(";")[0] !== "application/x-www-form-urlencoded") { @@ -46,7 +62,7 @@ export async function action({ request }: ActionFunctionArgs) { return json( { ok: false, - error: "Enter 1–365 days and up to 100 valid branch names, one per line.", + error: "Enter 1–365 days and up to 100 valid branch names.", } as const, { status: 400 } ); @@ -137,61 +153,43 @@ export function AutoArchiveSettings({ canManage: boolean; }) { const [open, setOpen] = useState(false); - const fetcher = useFetcher(); - const toggleRef = useRef(null); + const triggerRef = useRef(null); const enabled = environment.previewAutoArchiveAfterDays !== null; - function handleToggle(checked: boolean) { - if (checked) { - setOpen(true); - } else { - fetcher.submit( - { - environmentId: environment.id, - intent: "save", - excludedBranches: environment.previewAutoArchiveExcludedBranches.join("\n"), - }, - { method: "post", action: "/resources/branches/auto-archive" } - ); - } - } - return ( -
-
- + - - )} -
- {fetcher.data && !fetcher.data.ok && {fetcher.data.error}} -
+ LeadingIcon={Cog6ToothIcon} + className="shrink-0 whitespace-nowrap" + disabled={!canManage} + > + Manage auto-archive + + + ) : ( + setOpen(true)} + disabled={!canManage} + /> + )} { event.preventDefault(); - toggleRef.current?.focus(); + triggerRef.current?.focus(); }} > - Auto-archive preview branches + + Auto-archive preview branches + {open && setOpen(false)} />}
@@ -216,9 +214,44 @@ function AutoArchiveForm({ }>({ pending: false }); const [requestedKey, setRequestedKey] = useState(null); const [days, setDays] = useState(String(environment.previewAutoArchiveAfterDays ?? 14)); - const [excluded, setExcluded] = useState( - environment.previewAutoArchiveExcludedBranches.join("\n") + const [keepSpecificBranches, setKeepSpecificBranches] = useState( + environment.previewAutoArchiveExcludedBranches.length > 0 ); + const [excludedRows, setExcludedRows] = useState(() => { + const names = environment.previewAutoArchiveExcludedBranches; + return (names.length < 100 ? [...names, ""] : names).map((name, id) => ({ id, name })); + }); + const nextRowId = useRef(excludedRows.length); + const excluded = keepSpecificBranches ? excludedRows.map(({ name }) => name).join("\n") : ""; + const enabled = environment.previewAutoArchiveAfterDays !== null; + + function updateExcludedBranch(id: number, name: string) { + const rows = excludedRows.map((row) => (row.id === id ? { ...row, name } : row)); + if (rows.every((row) => row.name.trim() !== "") && rows.length < 100) { + rows.push({ id: nextRowId.current++, name: "" }); + } + setExcludedRows(rows); + } + + function removeExcludedBranch(id: number) { + const rows = excludedRows.filter((row) => row.id !== id); + if (rows.length === 0 || rows.every((row) => row.name.trim() !== "")) { + rows.push({ id: nextRowId.current++, name: "" }); + } + setExcludedRows(rows); + } + + function disableAutoArchive() { + crumb("disable preview auto-archive", { environmentId: environment.id }); // @crumbs + fetcher.submit( + { + environmentId: environment.id, + intent: "save", + excludedBranches: environment.previewAutoArchiveExcludedBranches.join("\n"), + }, + { method: "post", action: "/resources/branches/auto-archive" } + ); + } const policyKey = JSON.stringify({ days: Number(days), excludedBranches: [ @@ -233,6 +266,13 @@ function AutoArchiveForm({ const valid = PreviewAutoArchivePolicy.safeParse(JSON.parse(policyKey)).success; const reviewed = valid && !preview.pending && preview.data?.ok && preview.data.policyKey === policyKey; + const previewError = + requestedKey === policyKey && !preview.pending && preview.data && !preview.data.ok + ? preview.data.error + : undefined; + const checkingPreview = valid && !reviewed && !previewError; + const protectedBranches = + valid && !previewError && preview.data?.ok ? preview.data.protectedBranches : []; const busy = fetcher.state !== "idle"; useEffect(() => { if (!valid || busy) return; @@ -240,7 +280,8 @@ function AutoArchiveForm({ const timer = setTimeout(async () => { const policy = JSON.parse(policyKey) as { days: number; excludedBranches: string[] }; setRequestedKey(policyKey); - setPreview({ pending: true }); + // Keep the previous results and their height while refreshing the preview. + setPreview((previous) => ({ ...previous, pending: true })); try { // A read-only POST avoids URL-size limits for exclusions and Remix's // page-wide action revalidation while the user edits the form. @@ -283,89 +324,174 @@ function AutoArchiveForm({ > -
- - setDays(event.target.value)} - /> -
-
- -