diff --git a/.gitignore b/.gitignore index 1dac9aa9c1..2e3a5ed3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ apps/**/public/build .yarn *.tsbuildinfo /packages/cli-v3/src/package.json +.husky +/packages/react-hooks/src/package.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 346c458900..f8a7bd0697 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "search.exclude": { "**/node_modules/**": true, "packages/cli-v3/e2e": true - } + }, + "vitest.disableWorkspaceWarning": true } diff --git a/apps/webapp/app/assets/images/queues-dashboard.png b/apps/webapp/app/assets/images/queues-dashboard.png new file mode 100644 index 0000000000..321c79e629 Binary files /dev/null and b/apps/webapp/app/assets/images/queues-dashboard.png differ diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index b0b49fa2cb..6c0dca843a 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -6,6 +6,7 @@ import { ClockIcon, PlusIcon, RectangleGroupIcon, + RectangleStackIcon, ServerStackIcon, Squares2X2Icon, } from "@heroicons/react/20/solid"; @@ -368,35 +369,28 @@ export function AlertsNoneDeployed() { ); } -function AlertsNoneProd() { +export function QueuesHasNoTasks() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + return ( -
- + + This means you haven't got any tasks yet in this environment. + + - - You can get alerted when deployed runs fail. - - - We don't support alerts in the Development environment. Switch to a deployed environment - to setup alerts. - -
- - How to setup alerts - -
-
- -
+ Add tasks + + ); } diff --git a/apps/webapp/app/components/SetupCommands.tsx b/apps/webapp/app/components/SetupCommands.tsx index c711e6c3df..0a21bc54ce 100644 --- a/apps/webapp/app/components/SetupCommands.tsx +++ b/apps/webapp/app/components/SetupCommands.tsx @@ -1,7 +1,6 @@ import { createContext, useContext, useState } from "react"; import { useAppOrigin } from "~/hooks/useAppOrigin"; import { useProject } from "~/hooks/useProject"; -import { InlineCode } from "./code/InlineCode"; import { ClientTabs, ClientTabsContent, @@ -9,7 +8,6 @@ import { ClientTabsTrigger, } from "./primitives/ClientTabs"; import { ClipboardField } from "./primitives/ClipboardField"; -import { Paragraph } from "./primitives/Paragraph"; import { Header3 } from "./primitives/Headers"; type PackageManagerContextType = { diff --git a/apps/webapp/app/components/admin/debugTooltip.tsx b/apps/webapp/app/components/admin/debugTooltip.tsx index 2dfcce9634..b4ccb74f88 100644 --- a/apps/webapp/app/components/admin/debugTooltip.tsx +++ b/apps/webapp/app/components/admin/debugTooltip.tsx @@ -6,6 +6,7 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/primitives/Tooltip"; +import { useOptionalEnvironment } from "~/hooks/useEnvironment"; import { useIsImpersonating, useOptionalOrganization } from "~/hooks/useOrganizations"; import { useOptionalProject } from "~/hooks/useProject"; import { useHasAdminAccess, useUser } from "~/hooks/useUser"; @@ -35,6 +36,7 @@ export function AdminDebugTooltip({ children }: { children?: React.ReactNode }) function Content({ children }: { children: React.ReactNode }) { const organization = useOptionalOrganization(); const project = useOptionalProject(); + const environment = useOptionalEnvironment(); const user = useUser(); return ( @@ -62,6 +64,22 @@ function Content({ children }: { children: React.ReactNode }) { )} + {environment && ( + <> + + Environment ID + {environment.id} + + + Environment type + {environment.type} + + + Environment paused + {environment.paused ? "Yes" : "No"} + + + )}
{children}
diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index a38607aab7..e45836e496 100644 --- a/apps/webapp/app/components/layout/AppLayout.tsx +++ b/apps/webapp/app/components/layout/AppLayout.tsx @@ -1,6 +1,4 @@ -import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; -import { useShowUpgradePrompt } from "../billing/UpgradePrompt"; /** This container is used to surround the entire app, it correctly places the nav bar */ export function AppContainer({ children }: { children: React.ReactNode }) { @@ -13,19 +11,7 @@ export function MainBody({ children }: { children: React.ReactNode }) { /** This container should be placed around the content on a page */ export function PageContainer({ children }: { children: React.ReactNode }) { - const organization = useOptionalOrganization(); - const showUpgradePrompt = useShowUpgradePrompt(organization); - - return ( -
- {children} -
- ); + return
{children}
; } export function PageBody({ diff --git a/apps/webapp/app/components/metrics/BigNumber.tsx b/apps/webapp/app/components/metrics/BigNumber.tsx new file mode 100644 index 0000000000..2097ba928b --- /dev/null +++ b/apps/webapp/app/components/metrics/BigNumber.tsx @@ -0,0 +1,55 @@ +import { type ReactNode } from "react"; +import { AnimatedNumber } from "../primitives/AnimatedNumber"; +import { Spinner } from "../primitives/Spinner"; +import { cn } from "~/utils/cn"; + +interface BigNumberProps { + title: ReactNode; + animate?: boolean; + loading?: boolean; + value?: number; + valueClassName?: string; + defaultValue?: number; + accessory?: ReactNode; + suffix?: string; + suffixClassName?: string; +} + +export function BigNumber({ + title, + value, + defaultValue, + valueClassName, + suffix, + suffixClassName, + accessory, + animate = false, + loading = false, +}: BigNumberProps) { + const v = value ?? defaultValue; + return ( +
+
+
{title}
+ {accessory &&
{accessory}
} +
+
+ {loading ? ( + + ) : v !== undefined ? ( +
+ {animate ? : v} + {suffix &&
{suffix}
} +
+ ) : ( + "–" + )} +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx index 4971a41fbc..0c04044d91 100644 --- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx +++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx @@ -22,9 +22,8 @@ export function AccountSideMenu({ user }: { user: User }) { to={rootPath()} fullWidth textAlignLeft - className="text-text-bright" > - Back to app + Back to app
diff --git a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx new file mode 100644 index 0000000000..bc0501a210 --- /dev/null +++ b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx @@ -0,0 +1,57 @@ +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; +import { useLocation } from "@remix-run/react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useOptionalEnvironment } from "~/hooks/useEnvironment"; +import { useOptionalOrganization } from "~/hooks/useOrganizations"; +import { useOptionalProject } from "~/hooks/useProject"; +import { v3QueuesPath } from "~/utils/pathBuilder"; +import { environmentFullTitle } from "../environments/EnvironmentLabel"; +import { LinkButton } from "../primitives/Buttons"; +import { Icon } from "../primitives/Icon"; +import { Paragraph } from "../primitives/Paragraph"; + +export function EnvironmentPausedBanner() { + const organization = useOptionalOrganization(); + const project = useOptionalProject(); + const environment = useOptionalEnvironment(); + const location = useLocation(); + + const hideButton = location.pathname.endsWith("/queues"); + + return ( + + {organization && project && environment && environment.paused ? ( + +
+ + + {environmentFullTitle(environment)} environment paused. No new runs will be dequeued + and executed. + +
+ {hideButton ? null : ( +
+ + Manage + +
+ )} +
+ ) : null} +
+ ); +} + +export function useShowEnvironmentPausedBanner() { + const environment = useOptionalEnvironment(); + const shouldShow = environment?.paused ?? false; + return { shouldShow }; +} diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 90504c9dfa..31a7c8abf2 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -20,11 +20,14 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; +import { Paragraph } from "../primitives/Paragraph"; export function OrganizationSettingsSideMenu({ organization, + version, }: { organization: MatchedOrganization; + version: string; }) { const { isManagedCloud } = useFeatures(); const currentPlan = useCurrentPlan(); @@ -42,48 +45,55 @@ export function OrganizationSettingsSideMenu({ to={rootPath()} fullWidth textAlignLeft - className="text-text-bright" > - Back to app + Back to app
-
- - - {isManagedCloud && ( +
+
+ - )} - - + {isManagedCloud && ( + + )} + + +
+
+ + + v{version} + +
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index b4298ff402..76501775a0 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -44,13 +44,13 @@ import { v3ApiKeysPath, v3BatchesPath, v3BillingPath, - v3ConcurrencyPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, v3ProjectAlertsPath, v3ProjectPath, v3ProjectSettingsPath, + v3QueuesPath, v3RunsPath, v3SchedulesPath, v3TestPath, @@ -189,6 +189,13 @@ export function SideMenu({ to={v3SchedulesPath(organization, project, environment)} data-action="schedules" /> + - - Math.round(current).toLocaleString()); + + useEffect(() => { + spring.set(value); + }, [spring, value]); + + return {display}; +} diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index beb347a337..04a033ba02 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -7,7 +7,7 @@ const variants = { small: "grid place-items-center rounded-full px-[0.4rem] h-4 tracking-wider text-xxs bg-background-dimmed text-text-dimmed uppercase whitespace-nowrap", "extra-small": - "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 tracking-wide text-xxs bg-background-bright text-blue-500 whitespace-nowrap", + "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 whitespace-nowrap", outline: "grid place-items-center rounded-sm px-1.5 h-5 tracking-wider text-xxs border border-dimmed text-text-dimmed uppercase whitespace-nowrap", "outline-rounded": diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index e9749f84eb..767aa3fab4 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -1,10 +1,11 @@ import { Link, useNavigation } from "@remix-run/react"; -import { ReactNode } from "react"; +import { type ReactNode } from "react"; import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { UpgradePrompt, useShowUpgradePrompt } from "../billing/UpgradePrompt"; import { BreadcrumbIcon } from "./BreadcrumbIcon"; import { Header2 } from "./Headers"; import { LoadingBarDivider } from "./LoadingBarDivider"; +import { EnvironmentPausedBanner } from "../navigation/EnvironmentPausedBanner"; type WithChildren = { children: React.ReactNode; @@ -14,6 +15,7 @@ type WithChildren = { export function NavBar({ children }: WithChildren) { const organization = useOptionalOrganization(); const showUpgradePrompt = useShowUpgradePrompt(organization); + const navigation = useNavigation(); const isLoading = navigation.state === "loading" || navigation.state === "submitting"; @@ -23,7 +25,11 @@ export function NavBar({ children }: WithChildren) {
{children}
- {showUpgradePrompt.shouldShow && organization && } + {showUpgradePrompt.shouldShow && organization ? ( + + ) : ( + + )}
); } diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index 0e20333c97..a557e5cd35 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -2,6 +2,7 @@ import { ClockIcon, HandRaisedIcon, InformationCircleIcon, + RectangleStackIcon, Squares2X2Icon, TagIcon, } from "@heroicons/react/20/solid"; @@ -59,6 +60,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { return ; case "tag": return ; + case "queue": + return ; //log levels case "debug": case "log": diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 84e3c52b91..4ff67c0439 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -568,7 +568,6 @@ const EnvironmentSchema = z.object({ /** How long should the presence ttl last */ DEV_PRESENCE_TTL_MS: z.coerce.number().int().default(30_000), DEV_PRESENCE_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000), - DEV_PRESENCE_RECONNECT_THRESHOLD_MS: z.coerce.number().int().default(2_000), /** How many ms to wait until dequeuing again, if there was a run last time */ DEV_DEQUEUE_INTERVAL_WITH_RUN: z.coerce.number().int().default(250), /** How many ms to wait until dequeuing again, if there was no run last time */ @@ -660,6 +659,9 @@ const EnvironmentSchema = z.object({ TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"), TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute + + QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000), + QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000), }); export type Environment = z.infer; diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index c70d7fdb43..12e7359056 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -69,7 +69,11 @@ export async function findEnvironmentById(id: string): Promise { return prisma.runtimeEnvironment.findFirst({ where: { projectId: projectId, @@ -88,6 +92,11 @@ export async function findEnvironmentBySlug(projectId: string, envSlug: string, }, ], }, + include: { + project: true, + organization: true, + orgMember: true, + }, }); } diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 1ac6685944..415a371e84 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -75,6 +75,7 @@ export class OrganizationsPresenter { id: true, type: true, slug: true, + paused: true, orgMember: { select: { userId: true, diff --git a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts index 5ca3458b99..67abdc808e 100644 --- a/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts +++ b/apps/webapp/app/presenters/SelectBestEnvironmentPresenter.server.ts @@ -1,9 +1,13 @@ -import { type RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database"; +import { + type RuntimeEnvironment, + type PrismaClient, + RuntimeEnvironmentType, +} from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { type UserFromSession } from "~/services/session.server"; -export type MinimumEnvironment = Pick & { +export type MinimumEnvironment = Pick & { orgMember: null | { userId: string | undefined; }; @@ -45,6 +49,7 @@ export class SelectBestEnvironmentPresenter { id: true, type: true, slug: true, + paused: true, orgMember: { select: { userId: true, @@ -68,6 +73,7 @@ export class SelectBestEnvironmentPresenter { id: true, type: true, slug: true, + paused: true, orgMember: { select: { userId: true, @@ -133,11 +139,9 @@ export class SelectBestEnvironmentPresenter { return projects.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).at(0); } - async selectBestEnvironment( - projectId: string, - user: UserFromSession, - environments: MinimumEnvironment[] - ): Promise { + async selectBestEnvironment< + T extends { id: string; type: RuntimeEnvironmentType; orgMember: { userId: string } | null } + >(projectId: string, user: UserFromSession, environments: T[]): Promise { //try get current environment from prefs const currentEnvironmentId: string | undefined = user.dashboardPreferences.projects[projectId]?.currentEnvironment.id; diff --git a/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts b/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts deleted file mode 100644 index 1762c2e404..0000000000 --- a/apps/webapp/app/presenters/v3/ConcurrencyPresenter.server.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus"; -import { Prisma, sqlDatabaseSchema } from "~/db.server"; -import { type Project } from "~/models/project.server"; -import { - displayableEnvironment, - type DisplayableInputEnvironment, -} from "~/models/runtimeEnvironment.server"; -import { type User } from "~/models/user.server"; -import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort"; -import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server"; -import { BasePresenter } from "./basePresenter.server"; -import { execute } from "effect/Stream"; -import { engine } from "~/v3/runEngine.server"; - -export type Environment = Awaited< - ReturnType ->[number]; - -export class ConcurrencyPresenter extends BasePresenter { - public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) { - const project = await this._replica.project.findFirst({ - select: { - id: true, - organizationId: true, - environments: { - select: { - id: true, - apiKey: true, - pkApiKey: true, - type: true, - slug: true, - updatedAt: true, - orgMember: { - select: { - user: { select: { id: true, name: true, displayName: true } }, - }, - }, - maximumConcurrencyLimit: true, - }, - }, - }, - where: { - slug: projectSlug, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - }); - - if (!project) { - throw new Error(`Project not found: ${projectSlug}`); - } - - return { - environments: this.environmentConcurrency( - project.organizationId, - project.id, - userId, - filterOrphanedEnvironments(project.environments) - ), - }; - } - - async environmentConcurrency( - organizationId: string, - projectId: string, - userId: string, - environments: (DisplayableInputEnvironment & { maximumConcurrencyLimit: number })[] - ) { - const engineV1Concurrency = await concurrencyTracker.environmentConcurrentRunCounts( - projectId, - environments.map((env) => env.id) - ); - - const engineV2Concurrency = await Promise.all( - environments.map(async (env) => - engine.currentConcurrencyOfEnvQueue({ - ...env, - project: { - id: projectId, - }, - organization: { - id: organizationId, - }, - }) - ) - ); - - //Build `executingCounts` with both v1 and v2 concurrencies - const executingCounts: Record = engineV1Concurrency; - - for (let index = 0; index < environments.length; index++) { - const env = environments[index]; - const existingValue: number | undefined = executingCounts[env.id]; - executingCounts[env.id] = engineV2Concurrency[index] + (existingValue ?? 0); - } - - //todo add Run Engine 2 concurrency count - - const queued = await this._replica.$queryRaw< - { - runtimeEnvironmentId: string; - count: BigInt; - }[] - >` -SELECT - "runtimeEnvironmentId", - COUNT(*) -FROM - ${sqlDatabaseSchema}."TaskRun" as tr -WHERE - tr."projectId" = ${projectId} - AND tr."status" = ANY(ARRAY[${Prisma.join(QUEUED_STATUSES)}]::\"TaskRunStatus\"[]) -GROUP BY - tr."runtimeEnvironmentId";`; - - const sortedEnvironments = sortEnvironments(environments).map((environment) => ({ - ...displayableEnvironment(environment, userId), - concurrencyLimit: environment.maximumConcurrencyLimit, - concurrency: executingCounts[environment.id] ?? 0, - queued: Number(queued.find((q) => q.runtimeEnvironmentId === environment.id)?.count ?? 0), - })); - - return sortedEnvironments; - } -} diff --git a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts new file mode 100644 index 0000000000..7469a2c0b1 --- /dev/null +++ b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts @@ -0,0 +1,31 @@ +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { marqs } from "~/v3/marqs/index.server"; +import { engine } from "~/v3/runEngine.server"; +import { BasePresenter } from "./basePresenter.server"; + +export type Environment = { + running: number; + queued: number; + concurrencyLimit: number; +}; + +export class EnvironmentQueuePresenter extends BasePresenter { + async call(environment: AuthenticatedEnvironment): Promise { + const [engineV1Executing, engineV2Executing, engineV1Queued, engineV2Queued] = + await Promise.all([ + marqs.currentConcurrencyOfEnvironment(environment), + engine.concurrencyOfEnvQueue(environment), + marqs.lengthOfEnvQueue(environment), + engine.lengthOfEnvQueue(environment), + ]); + + const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0); + const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0); + + return { + running, + queued, + concurrencyLimit: environment.maximumConcurrencyLimit, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts new file mode 100644 index 0000000000..3020d718e0 --- /dev/null +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -0,0 +1,98 @@ +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; +import { engine } from "~/v3/runEngine.server"; +import { BasePresenter } from "./basePresenter.server"; +import { toQueueItem } from "./QueueRetrievePresenter.server"; + +const DEFAULT_ITEMS_PER_PAGE = 25; +const MAX_ITEMS_PER_PAGE = 100; +export class QueueListPresenter extends BasePresenter { + private readonly perPage: number; + + constructor(perPage: number = DEFAULT_ITEMS_PER_PAGE) { + super(); + this.perPage = Math.min(perPage, MAX_ITEMS_PER_PAGE); + } + + public async call({ + environment, + page, + }: { + environment: AuthenticatedEnvironment; + page: number; + perPage?: number; + }) { + // Get total count for pagination + const totalQueues = await this._replica.taskQueue.count({ + where: { + runtimeEnvironmentId: environment.id, + }, + }); + + //check the engine is the correct version + const engineVersion = await determineEngineVersion({ environment }); + + if (engineVersion === "V1") { + return { + success: false as const, + code: "engine-version", + totalQueues, + }; + } + + return { + success: true as const, + queues: await this.getQueuesWithPagination(environment, page), + pagination: { + currentPage: page, + totalPages: Math.ceil(totalQueues / this.perPage), + count: totalQueues, + }, + totalQueues, + }; + } + + private async getQueuesWithPagination(environment: AuthenticatedEnvironment, page: number) { + const queues = await this._replica.taskQueue.findMany({ + where: { + runtimeEnvironmentId: environment.id, + }, + select: { + friendlyId: true, + name: true, + concurrencyLimit: true, + type: true, + paused: true, + }, + orderBy: { + name: "asc", + }, + skip: (page - 1) * this.perPage, + take: this.perPage, + }); + + const results = await Promise.all([ + engine.lengthOfQueues( + environment, + queues.map((q) => q.name) + ), + engine.currentConcurrencyOfQueues( + environment, + queues.map((q) => q.name) + ), + ]); + + // Transform queues to include running and queued counts + return queues.map((queue) => + toQueueItem({ + friendlyId: queue.friendlyId, + name: queue.name, + type: queue.type, + running: results[1][queue.name] ?? 0, + queued: results[0][queue.name] ?? 0, + concurrencyLimit: queue.concurrencyLimit ?? null, + paused: queue.paused, + }) + ); + } +} diff --git a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts new file mode 100644 index 0000000000..6f297bc8ba --- /dev/null +++ b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts @@ -0,0 +1,119 @@ +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { engine } from "~/v3/runEngine.server"; +import { BasePresenter } from "./basePresenter.server"; +import { type TaskQueueType } from "@trigger.dev/database"; +import { assertExhaustive } from "@trigger.dev/core"; +import { determineEngineVersion } from "~/v3/engineVersion.server"; +import { type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3"; +import { PrismaClientOrTransaction } from "@trigger.dev/database"; + +/** + * Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService + */ +export async function getQueue( + prismaClient: PrismaClientOrTransaction, + environment: AuthenticatedEnvironment, + queue: RetrieveQueueParam +) { + if (typeof queue === "string") { + return prismaClient.taskQueue.findFirst({ + where: { + friendlyId: queue, + runtimeEnvironmentId: environment.id, + }, + }); + } + + const queueName = + queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name; + return prismaClient.taskQueue.findFirst({ + where: { + name: queueName, + runtimeEnvironmentId: environment.id, + }, + }); +} + +export class QueueRetrievePresenter extends BasePresenter { + public async call({ + environment, + queueInput, + }: { + environment: AuthenticatedEnvironment; + queueInput: RetrieveQueueParam; + }) { + //check the engine is the correct version + const engineVersion = await determineEngineVersion({ environment }); + + if (engineVersion === "V1") { + return { + success: false as const, + code: "engine-version", + }; + } + + const queue = await getQueue(this._replica, environment, queueInput); + if (!queue) { + return { + success: false as const, + code: "queue-not-found", + }; + } + + const results = await Promise.all([ + engine.lengthOfQueues(environment, [queue.name]), + engine.currentConcurrencyOfQueues(environment, [queue.name]), + ]); + + // Transform queues to include running and queued counts + return { + success: true as const, + queue: toQueueItem({ + friendlyId: queue.friendlyId, + name: queue.name, + type: queue.type, + running: results[1]?.[queue.name] ?? 0, + queued: results[0]?.[queue.name] ?? 0, + concurrencyLimit: queue.concurrencyLimit ?? null, + paused: queue.paused, + }), + }; + } +} + +function queueTypeFromType(type: TaskQueueType) { + switch (type) { + case "NAMED": + return "custom" as const; + case "VIRTUAL": + return "task" as const; + default: + assertExhaustive(type); + } +} + +/** + * Converts raw queue data into a standardized QueueItem format + * @param data Raw queue data containing required queue properties + * @returns A validated QueueItem object + */ +export function toQueueItem(data: { + friendlyId: string; + name: string; + type: TaskQueueType; + running: number; + queued: number; + concurrencyLimit: number | null; + paused: boolean; +}): QueueItem { + return { + id: data.friendlyId, + //remove the task/ prefix if it exists + name: data.name.replace(/^task\//, ""), + type: queueTypeFromType(data.type), + running: data.running, + queued: data.queued, + concurrencyLimit: data.concurrencyLimit, + paused: data.paused, + }; +} 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 deleted file mode 100644 index 5635b46c17..0000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - ArrowUpCircleIcon, - BookOpenIcon, - ChatBubbleLeftEllipsisIcon, -} from "@heroicons/react/20/solid"; -import { LockOpenIcon } from "@heroicons/react/24/solid"; -import { Await, type MetaFunction } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { Suspense } from "react"; -import { typeddefer, useTypedLoaderData } from "remix-typedjson"; -import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; -import { Feedback } from "~/components/Feedback"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { Spinner } from "~/components/primitives/Spinner"; -import { - Table, - TableBody, - TableCell, - TableHeader, - TableHeaderCell, - TableRow, -} from "~/components/primitives/Table"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { - ConcurrencyPresenter, - type Environment, -} from "~/presenters/v3/ConcurrencyPresenter.server"; -import { requireUserId } from "~/services/session.server"; -import { docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder"; -import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; - -export const meta: MetaFunction = () => { - return [ - { - title: `Concurrency limits | Trigger.dev`, - }, - ]; -}; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam } = ProjectParamSchema.parse(params); - - try { - const presenter = new ConcurrencyPresenter(); - const result = await presenter.call({ - userId, - projectSlug: projectParam, - }); - - return typeddefer(result); - } catch (error) { - console.error(error); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", - }); - } -}; - -export default function Page() { - const { environments } = useTypedLoaderData(); - - const organization = useOrganization(); - const plan = useCurrentPlan(); - - return ( - - - - - - - Concurrency docs - - - - -
- - - - Environment - Queued - Running - Concurrency limit - - - - - -
- -
-
- - } - > - Error loading environments

}> - {(environments) => } -
-
-
-
- {plan ? ( - plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? ( -
- - Need more concurrency? - - - Request more - - } - defaultValue="help" - /> -
- ) : ( -
- - - Upgrade for more concurrency - - - Upgrade - -
- ) - ) : null} -
-
-
- ); -} - -function EnvironmentsTable({ environments }: { environments: Environment[] }) { - return ( - <> - {environments.map((environment) => ( - - - - - {environment.queued} - {environment.concurrency} - {environment.concurrencyLimit} - - ))} - - ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 32945e7375..a22a6ad9b8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -48,6 +48,7 @@ import { DeploymentListPresenter, } from "~/presenters/v3/DeploymentListPresenter.server"; import { requireUserId } from "~/services/session.server"; +import { titleCase } from "~/utils"; import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus"; @@ -174,7 +175,7 @@ export default function Page() {
{deployment.shortCode} {deployment.label && ( - {deployment.label} + {titleCase(deployment.label)} )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx new file mode 100644 index 0000000000..b6638f878d --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -0,0 +1,631 @@ +import { + ArrowUpCircleIcon, + BookOpenIcon, + ChatBubbleLeftEllipsisIcon, + PauseIcon, + PlayIcon, + RectangleStackIcon, +} from "@heroicons/react/20/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useNavigation, useRevalidator, type MetaFunction } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type RuntimeEnvironmentType } from "@trigger.dev/database"; +import { useEffect, useState } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; +import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; +import { Feedback } from "~/components/Feedback"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { BigNumber } from "~/components/metrics/BigNumber"; +import { Badge } from "~/components/primitives/Badge"; +import { Button, LinkButton } 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 { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { + Table, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { + SimpleTooltip, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/primitives/Tooltip"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useEventSource } from "~/hooks/useEventSource"; +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 { 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 { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; +import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; +import { PauseQueueService } from "~/v3/services/pauseQueue.server"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { QueuesHasNoTasks } from "~/components/BlankStatePanels"; + +const SearchParamsSchema = z.object({ + 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 } = 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, + page, + }); + + const environmentQueuePresenter = new EnvironmentQueuePresenter(); + + return typedjson({ + ...queues, + environment: await environmentQueuePresenter.call(environment), + }); + } 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 { page } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); + + const redirectPath = `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues?page=${page}`; + + switch (action) { + case "environment-pause": + const pauseService = new PauseEnvironmentService(); + await pauseService.call(environment, "paused"); + return redirectWithSuccessMessage(redirectPath, request, "Environment paused"); + case "environment-resume": + const resumeService = new PauseEnvironmentService(); + await resumeService.call(environment, "resumed"); + 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"}` + ); + } + default: + return redirectWithErrorMessage(redirectPath, request, "Something went wrong"); + } +}; + +export default function Page() { + const { environment, queues, success, pagination, code, totalQueues } = + useTypedLoaderData(); + + const organization = useOrganization(); + const project = useProject(); + const env = useEnvironment(); + const plan = useCurrentPlan(); + + // Reload the page periodically + const streamedEvents = useEventSource( + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`, + { + event: "update", + } + ); + + const revalidation = useRevalidator(); + useEffect(() => { + if (streamedEvents) { + revalidation.revalidate(); + } + }, [streamedEvents]); + + return ( + + + + + + + Queues docs + + + + +
+
+ 0 ? "paused" : undefined} + animate + accessory={} + valueClassName={env.paused ? "text-amber-500" : undefined} + /> + + + Increase limit + + } + defaultValue="help" + /> + ) : ( + + Increase limit + + ) + ) : null + } + /> +
+ + {success ? ( +
1 && "grid-rows-[1fr_auto]" + )} + > + + + + Name + Queued + Running + Concurrency limit + + Pause/resume + + + + + {queues.length > 0 ? ( + queues.map((queue) => ( + + + + {queue.type === "task" ? ( + + } + content={`This queue was automatically created from your "${queue.name}" task`} + /> + ) : ( + + } + content={`This is a custom queue you added in your code.`} + /> + )} + + {queue.name} + + {queue.paused ? ( + + Paused + + ) : null} + + + + {queue.queued} + + + {queue.running} + + + {queue.concurrencyLimit ?? ( + + Max ({environment.concurrencyLimit}) + + )} + + } + hiddenButtons={!queue.paused && } + /> + + )) + ) : ( + + +
+ No queues found +
+
+
+ )} +
+
+ + {pagination.totalPages > 1 && ( +
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" + )} + > +
1 && + "justify-end border-t border-grid-dimmed px-2 py-3" + )} + > + +
+
+ )} +
+ ) : ( +
+ {code === "engine-version" ? ( + totalQueues === 0 ? ( +
+ +
+ ) : ( + + ) + ) : ( + Something went wrong + )} +
+ )} +
+
+
+ ); +} + +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 ( + +
+ + + +
+ + + +
+
+ + {env.paused + ? `Resume processing runs in ${environmentFullTitle(env)}.` + : `Pause processing runs in ${environmentFullTitle(env)}.`} + +
+
+
+ + {env.paused ? "Resume environment?" : "Pause environment?"} +
+ + {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.`} + +
setIsOpen(false)}> + + : env.paused ? PlayIcon : PauseIcon} + shortcut={{ modifiers: ["mod"], key: "enter" }} + > + {env.paused ? "Resume environment" : "Pause environment"} + + } + cancelButton={ + + + + } + /> + +
+
+
+ ); +} + +function QueuePauseResumeButton({ + queue, +}: { + /** The "id" here is a friendlyId */ + queue: { id: string; name: string; paused: boolean }; +}) { + const navigation = useNavigation(); + const [isOpen, setIsOpen] = useState(false); + + return ( + +
+ + + +
+ + + +
+
+ + {queue.paused + ? `Resume processing runs in queue "${queue.name}"` + : `Pause processing runs in queue "${queue.name}"`} + +
+
+
+ + {queue.paused ? "Resume queue?" : "Pause queue?"} +
+ + {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.`} + +
setIsOpen(false)}> + + + + {queue.paused ? "Resume queue" : "Pause queue"} + + } + cancelButton={ + + + + } + /> + +
+
+
+ ); +} + +function EngineVersionUpgradeCallout() { + return ( +
+
+

New queues table

+ + Upgrade guide + +
+
+ + Upgrade to SDK version 4+ to view the new queues table, and be able to pause and resume + individual queues. + + Upgrade for queues +
+
+ ); +} + +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") + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx index 32f77ef904..735959e451 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -1,15 +1,25 @@ import { Outlet } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { VERSION } from "@trigger.dev/core"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AppContainer, MainBody } from "~/components/layout/AppLayout"; import { OrganizationSettingsSideMenu } from "~/components/navigation/OrganizationSettingsSideMenu"; import { useOrganization } from "~/hooks/useOrganizations"; +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + return typedjson({ + version: VERSION, + }); +}; + export default function Page() { + const { version } = useTypedLoaderData(); const organization = useOrganization(); return (
- + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 7700b7e20d..5a32667a26 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -11,6 +11,8 @@ import { getCachedUsage, getCurrentPlan } from "~/services/platform.v3.server"; import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; import { organizationPath } from "~/utils/pathBuilder"; +import { isEnvironmentPauseResumeFormSubmission } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ organizationSlug: z.string(), @@ -45,6 +47,11 @@ export const shouldRevalidate: ShouldRevalidateFunction = (params) => { } } + // Invalidate if the environment has been paused or resumed + if (isEnvironmentPauseResumeFormSubmission(params.formMethod, params.formData)) { + return true; + } + // This prevents revalidation when there are search params changes // IMPORTANT: If the loader function depends on search params, this should be updated return params.currentUrl.pathname !== params.nextUrl.pathname; diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts new file mode 100644 index 0000000000..452bd81746 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.pause.ts @@ -0,0 +1,46 @@ +import { json } from "@remix-run/server-runtime"; +import { type QueueItem, type RetrieveQueueParam, RetrieveQueueType } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { PauseQueueService } from "~/v3/services/pauseQueue.server"; + +const BodySchema = z.object({ + type: RetrieveQueueType.default("id"), + action: z.enum(["pause", "resume"]), +}); + +export const { action } = createActionApiRoute( + { + body: BodySchema, + params: z.object({ + queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), + }), + }, + async ({ params, body, authentication }) => { + const input: RetrieveQueueParam = + body.type === "id" + ? params.queueParam + : { + type: body.type, + name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"), + }; + + const service = new PauseQueueService(); + const result = await service.call( + authentication.environment, + input, + body.action === "pause" ? "paused" : "resumed" + ); + + if (!result.success) { + if (result.code === "queue-not-found") { + return json({ error: result.code }, { status: 404 }); + } + + return json({ error: result.code }, { status: 400 }); + } + + const q: QueueItem = result.queue; + return json(q); + } +); diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts new file mode 100644 index 0000000000..a9bcd2342e --- /dev/null +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.ts @@ -0,0 +1,45 @@ +import { json } from "@remix-run/server-runtime"; +import { type QueueItem, type RetrieveQueueParam, RetrieveQueueType } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { QueueRetrievePresenter } from "~/presenters/v3/QueueRetrievePresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const SearchParamsSchema = z.object({ + type: RetrieveQueueType.default("id"), +}); + +export const loader = createLoaderApiRoute( + { + params: z.object({ + queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")), + }), + searchParams: SearchParamsSchema, + findResource: async () => 1, // This is a dummy function, we don't need to find a resource + }, + async ({ params, searchParams, authentication }) => { + const input: RetrieveQueueParam = + searchParams.type === "id" + ? params.queueParam + : { + type: searchParams.type, + name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"), + }; + + const presenter = new QueueRetrievePresenter(); + const result = await presenter.call({ + environment: authentication.environment, + queueInput: input, + }); + + if (!result.success) { + if (result.code === "queue-not-found") { + return json({ error: result.code }, { status: 404 }); + } + + return json({ error: result.code }, { status: 400 }); + } + + const q: QueueItem = result.queue; + return json(q); + } +); diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts new file mode 100644 index 0000000000..551b3c2f34 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -0,0 +1,44 @@ +import { json } from "@remix-run/server-runtime"; +import { type QueueItem } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; + +const SearchParamsSchema = z.object({ + page: z.coerce.number().int().positive().optional(), + perPage: z.coerce.number().int().positive().optional(), +}); + +export const loader = createLoaderApiRoute( + { + searchParams: SearchParamsSchema, + findResource: async () => 1, // This is a dummy function, we don't need to find a resource + }, + async ({ searchParams, authentication }) => { + const service = new QueueListPresenter(searchParams.perPage); + + try { + const result = await service.call({ + environment: authentication.environment, + page: searchParams.page ?? 1, + }); + + if (!result.success) { + return json({ error: result.code }, { status: 400 }); + } + + const queues: QueueItem[] = result.queues; + return json({ data: queues, pagination: result.pagination }, { status: 200 }); + } catch (error) { + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: 422 }); + } + + return json( + { error: error instanceof Error ? error.message : "Internal Server Error" }, + { status: 500 } + ); + } + } +); diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts index 5a977cd5cc..caf714fd30 100644 --- a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts @@ -2,7 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { prisma } from "~/db.server"; import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { requireUser } from "~/services/session.server"; -import { ProjectParamSchema, v3ApiKeysPath, v3ConcurrencyPath } from "~/utils/pathBuilder"; +import { ProjectParamSchema, v3QueuesPath } from "~/utils/pathBuilder"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); @@ -39,5 +39,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const selector = new SelectBestEnvironmentPresenter(); const environment = await selector.selectBestEnvironment(project.id, user, project.environments); - return redirect(v3ConcurrencyPath({ slug: organizationSlug }, project, environment)); + return redirect(v3QueuesPath({ slug: organizationSlug }, project, environment)); }; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts new file mode 100644 index 0000000000..e5af3479c6 --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency.ts @@ -0,0 +1,9 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { EnvironmentParamSchema, v3QueuesPath } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + return redirect( + v3QueuesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }) + ); +}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx new file mode 100644 index 0000000000..007e2c4f7e --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.stream.tsx @@ -0,0 +1,55 @@ +import { $replica } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { createSSELoader } from "~/utils/sse"; + +export const loader = createSSELoader({ + timeout: env.QUEUE_SSE_AUTORELOAD_TIMEOUT_MS, + interval: env.QUEUE_SSE_AUTORELOAD_INTERVAL_MS, + debug: true, + handler: async ({ request, params }) => { + const userId = await requireUserId(request); + const { projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { + slug: envParam, + type: "DEVELOPMENT", + orgMember: { + userId, + }, + project: { + slug: projectParam, + }, + }, + }); + + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + return { + beforeStream: async () => { + logger.debug("Start queue page SSE session", { + environmentId: environment.id, + }); + }, + initStream: async ({ send }) => { + send({ event: "time", data: new Date().toISOString() }); + }, + iterator: async ({ send }) => { + send({ + event: "update", + data: new Date().toISOString(), + }); + }, + cleanup: async () => { + logger.debug("End queue page SSE session", { + environmentId: environment.id, + }); + }, + }; + }, +}); diff --git a/apps/webapp/app/routes/storybook.info-panel/route.tsx b/apps/webapp/app/routes/storybook.info-panel/route.tsx new file mode 100644 index 0000000000..0a5b4e3e77 --- /dev/null +++ b/apps/webapp/app/routes/storybook.info-panel/route.tsx @@ -0,0 +1,118 @@ +import { + BeakerIcon, + BellAlertIcon, + BookOpenIcon, + ClockIcon, + InformationCircleIcon, + PlusIcon, + RocketLaunchIcon, + ServerStackIcon, + Squares2X2Icon, +} from "@heroicons/react/20/solid"; +import { InfoPanel } from "~/components/primitives/InfoPanel"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; + +export default function Story() { + return ( +
+
+ {/* Basic Info Panel */} + + This is a basic info panel with title and default variant + + + {/* Info Panel with Button */} + + This panel includes a button in the top-right corner + + + {/* Upgrade Variant with Button */} + + This panel uses the upgrade variant with a call-to-action button + + + {/* Minimal Variant */} + + A minimal variant without a title + + + {/* Task Panel with Action */} + + A panel showing task information with a view action + + + {/* Getting Started Panel */} + + Begin your journey with our quick start guide + + + {/* Deployment Panel with Button */} + + Ready to deploy your changes to production + + + {/* Create New Panel */} + + Start a new project with our guided setup + + + {/* Batches Panel */} + + Information about batch processing + + + {/* Documentation Panel with Link */} + + Access our comprehensive documentation + +
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index bd451f6147..995bfdf50e 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -52,6 +52,10 @@ const stories: Story[] = [ name: "Free plan usage", slug: "free-plan-usage", }, + { + name: "Info panel", + slug: "info-panel", + }, { name: "Inline code", slug: "inline-code", diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index d28945218f..fae78713db 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -39,7 +39,12 @@ type ApiKeyRouteBuilderOptions< params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer : undefined, - authentication: ApiAuthenticationResultSuccess + authentication: ApiAuthenticationResultSuccess, + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined ) => Promise; shouldRetryNotFound?: boolean; authorization?: { @@ -179,7 +184,7 @@ export function createLoaderApiRoute< } // Find the resource - const resource = await findResource(parsedParams, authenticationResult); + const resource = await findResource(parsedParams, authenticationResult, parsedSearchParams); if (!resource) { return await wrapResponse( diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 332ed0aa0f..b46c3cac13 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -171,14 +171,6 @@ export function v3EnvironmentVariablesPath( return `${v3EnvironmentPath(organization, project, environment)}/environment-variables`; } -export function v3ConcurrencyPath( - organization: OrgForPath, - project: ProjectForPath, - environment: EnvironmentForPath -) { - return `${v3EnvironmentPath(organization, project, environment)}/concurrency`; -} - export function v3NewEnvironmentVariablesPath( organization: OrgForPath, project: ProjectForPath, @@ -311,6 +303,14 @@ export function v3NewSchedulePath( return `${v3EnvironmentPath(organization, project, environment)}/schedules/new`; } +export function v3QueuesPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/queues`; +} + export function v3BatchesPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/utils/sse.ts b/apps/webapp/app/utils/sse.ts index 9f3452cf93..8f396c092e 100644 --- a/apps/webapp/app/utils/sse.ts +++ b/apps/webapp/app/utils/sse.ts @@ -53,6 +53,26 @@ export function createSSELoader(options: SSEOptions) { ); }; + const createSafeSend = (originalSend: SendFunction): SendFunction => { + return (event) => { + try { + if (!internalController.signal.aborted) { + originalSend(event); + } + // If controller is aborted, silently ignore the send attempt + } catch (error) { + if (error instanceof Error) { + if (error.message?.includes("Controller is already closed")) { + // Silently handle controller closed errors + return; + } + log(`Error sending event: ${error.message}`); + } + throw error; // Re-throw other errors + } + }; + }; + const context: SSEContext = { id, request, @@ -115,12 +135,13 @@ export function createSSELoader(options: SSEOptions) { return eventStream(combinedSignal, function setup(send) { connections.add(id); + const safeSend = createSafeSend(send); async function run() { try { log("Initializing"); if (handlers.initStream) { - const shouldContinue = await handlers.initStream({ send }); + const shouldContinue = await handlers.initStream({ send: safeSend }); if (shouldContinue === false) { log("initStream returned false, so we'll stop the stream"); internalController.abort("Init requested stop"); @@ -138,7 +159,7 @@ export function createSSELoader(options: SSEOptions) { if (handlers.iterator) { try { - const shouldContinue = await handlers.iterator({ date, send }); + const shouldContinue = await handlers.iterator({ date, send: safeSend }); if (shouldContinue === false) { log("iterator return false, so we'll stop the stream"); internalController.abort("Iterator requested stop"); @@ -173,7 +194,7 @@ export function createSSELoader(options: SSEOptions) { log("Cleanup called"); if (handlers.cleanup) { try { - handlers.cleanup({ send }); + handlers.cleanup({ send: safeSend }); } catch (error) { log( `Error in cleanup handler: ${ diff --git a/apps/webapp/app/v3/engineVersion.server.ts b/apps/webapp/app/v3/engineVersion.server.ts index 1b514fc398..c1a32052ff 100644 --- a/apps/webapp/app/v3/engineVersion.server.ts +++ b/apps/webapp/app/v3/engineVersion.server.ts @@ -1,17 +1,25 @@ -import { RunEngineVersion, RuntimeEnvironmentType } from "@trigger.dev/database"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { RunEngineVersion, type RuntimeEnvironmentType } from "@trigger.dev/database"; import { findCurrentWorkerDeploymentWithoutTasks, findCurrentWorkerFromEnvironment, } from "./models/workerDeployment.server"; import { $replica } from "~/db.server"; +type Environment = { + id: string; + type: RuntimeEnvironmentType; + project: { + id: string; + engine: RunEngineVersion; + }; +}; + export async function determineEngineVersion({ environment, workerVersion, engineVersion: version, }: { - environment: AuthenticatedEnvironment; + environment: Environment; workerVersion?: string; engineVersion?: RunEngineVersion; }): Promise { @@ -36,7 +44,7 @@ export async function determineEngineVersion({ }, where: { projectId_runtimeEnvironmentId_version: { - projectId: environment.projectId, + projectId: environment.project.id, runtimeEnvironmentId: environment.id, version: workerVersion, }, diff --git a/apps/webapp/app/v3/runQueue.server.ts b/apps/webapp/app/v3/runQueue.server.ts index 7198456d39..e7aa13c5c5 100644 --- a/apps/webapp/app/v3/runQueue.server.ts +++ b/apps/webapp/app/v3/runQueue.server.ts @@ -1,14 +1,22 @@ -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { marqs } from "./marqs/index.server"; import { engine } from "./runEngine.server"; //This allows us to update MARQS and the RunQueue /** Updates MARQS and the RunQueue limits */ -export async function updateEnvConcurrencyLimits(environment: AuthenticatedEnvironment) { +export async function updateEnvConcurrencyLimits( + environment: AuthenticatedEnvironment, + maximumConcurrencyLimit?: number +) { + let updatedEnvironment = environment; + if (maximumConcurrencyLimit !== undefined) { + updatedEnvironment.maximumConcurrencyLimit = maximumConcurrencyLimit; + } + await Promise.allSettled([ - marqs?.updateEnvConcurrencyLimits(environment), - engine.runQueue.updateEnvConcurrencyLimits(environment), + marqs?.updateEnvConcurrencyLimits(updatedEnvironment), + engine.runQueue.updateEnvConcurrencyLimits(updatedEnvironment), ]); } diff --git a/apps/webapp/app/v3/services/pauseEnvironment.server.ts b/apps/webapp/app/v3/services/pauseEnvironment.server.ts new file mode 100644 index 0000000000..a3e029e565 --- /dev/null +++ b/apps/webapp/app/v3/services/pauseEnvironment.server.ts @@ -0,0 +1,68 @@ +import { type AuthenticatedEnvironment } from "@internal/testcontainers"; +import { type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { updateEnvConcurrencyLimits } from "../runQueue.server"; +import { WithRunEngine } from "./baseService.server"; + +export type PauseStatus = "paused" | "resumed"; + +export type PauseEnvironmentResult = + | { + success: true; + state: PauseStatus; + } + | { + success: false; + error: string; + }; + +export class PauseEnvironmentService extends WithRunEngine { + constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) { + super({ prisma }); + } + + public async call( + environment: AuthenticatedEnvironment, + action: PauseStatus + ): Promise { + try { + await this._prisma.runtimeEnvironment.update({ + where: { + id: environment.id, + }, + data: { + paused: action === "paused", + }, + }); + + if (action === "paused") { + logger.debug("PauseEnvironmentService: pausing environment", { + environmentId: environment.id, + }); + await updateEnvConcurrencyLimits(environment, 0); + } else { + logger.debug("PauseEnvironmentService: resuming environment", { + environmentId: environment.id, + }); + await updateEnvConcurrencyLimits(environment); + } + + return { + success: true, + state: action, + }; + } catch (error) { + logger.error("PauseEnvironmentService: error pausing environment", { + action, + environmentId: environment.id, + error, + }); + + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } +} diff --git a/apps/webapp/app/v3/services/pauseQueue.server.ts b/apps/webapp/app/v3/services/pauseQueue.server.ts new file mode 100644 index 0000000000..f4e18eab4b --- /dev/null +++ b/apps/webapp/app/v3/services/pauseQueue.server.ts @@ -0,0 +1,107 @@ +import { QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3"; +import { getQueue, toQueueItem } from "~/presenters/v3/QueueRetrievePresenter.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { BaseService } from "./baseService.server"; +import { determineEngineVersion } from "../engineVersion.server"; +import { removeQueueConcurrencyLimits, updateQueueConcurrencyLimits } from "../runQueue.server"; +import { engine } from "../runEngine.server"; + +export type PauseStatus = "paused" | "resumed"; + +export type PauseQueueResult = + | { + success: true; + state: PauseStatus; + queue: QueueItem; + } + | { + success: false; + code: "queue-not-found" | "unknown-error" | "engine-version"; + error?: string; + }; + +export class PauseQueueService extends BaseService { + public async call( + environment: AuthenticatedEnvironment, + queueInput: RetrieveQueueParam, + action: PauseStatus + ): Promise { + try { + //check the engine is the correct version + const engineVersion = await determineEngineVersion({ environment }); + + if (engineVersion === "V1") { + return { + success: false as const, + code: "engine-version", + error: "Upgrade to v4+ to pause/resume queues", + }; + } + + const queue = await getQueue(this._prisma, environment, queueInput); + + if (!queue) { + return { + success: false, + code: "queue-not-found", + }; + } + + const updatedQueue = await this._prisma.taskQueue.update({ + where: { + id: queue.id, + }, + data: { + paused: action === "paused", + }, + }); + + if (action === "paused") { + await updateQueueConcurrencyLimits(environment, queue.name, 0); + } else { + if (queue.concurrencyLimit) { + await updateQueueConcurrencyLimits(environment, queue.name, queue.concurrencyLimit); + } else { + await removeQueueConcurrencyLimits(environment, queue.name); + } + } + + logger.debug("PauseQueueService: queue state updated", { + queueId: queue.id, + action, + environmentId: environment.id, + }); + + const results = await Promise.all([ + engine.lengthOfQueues(environment, [queue.name]), + engine.currentConcurrencyOfQueues(environment, [queue.name]), + ]); + + return { + success: true, + state: action, + queue: toQueueItem({ + friendlyId: updatedQueue.friendlyId, + name: updatedQueue.name, + type: updatedQueue.type, + running: results[1]?.[updatedQueue.name] ?? 0, + queued: results[0]?.[updatedQueue.name] ?? 0, + concurrencyLimit: updatedQueue.concurrencyLimit ?? null, + paused: updatedQueue.paused, + }), + }; + } catch (error) { + logger.error("PauseQueueService: error updating queue state", { + error, + environmentId: environment.id, + }); + + return { + success: false, + code: "unknown-error", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } +} diff --git a/internal-packages/database/prisma/migrations/20250318090847_pause_queues_and_environments/migration.sql b/internal-packages/database/prisma/migrations/20250318090847_pause_queues_and_environments/migration.sql new file mode 100644 index 0000000000..4daf97b608 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250318090847_pause_queues_and_environments/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "RuntimeEnvironment" +ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "TaskQueue" +ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 0398103546..84d8c55702 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -382,7 +382,8 @@ model RuntimeEnvironment { ///A memorable code for the environment shortcode String - maximumConcurrencyLimit Int @default(5) + maximumConcurrencyLimit Int @default(5) + paused Boolean @default(false) autoEnableInternalSources Boolean @default(true) @@ -2523,6 +2524,8 @@ model TaskQueue { concurrencyLimit Int? rateLimit Json? + paused Boolean @default(false) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 834652fad6..46556b9588 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -1652,12 +1652,24 @@ export class RunEngine { return this.runQueue.lengthOfEnvQueue(environment); } - async currentConcurrencyOfEnvQueue( - environment: MinimalAuthenticatedEnvironment - ): Promise { + async concurrencyOfEnvQueue(environment: MinimalAuthenticatedEnvironment): Promise { return this.runQueue.currentConcurrencyOfEnvironment(environment); } + async lengthOfQueues( + environment: MinimalAuthenticatedEnvironment, + queues: string[] + ): Promise> { + return this.runQueue.lengthOfQueues(environment, queues); + } + + async currentConcurrencyOfQueues( + environment: MinimalAuthenticatedEnvironment, + queues: string[] + ): Promise> { + return this.runQueue.currentConcurrencyOfQueues(environment, queues); + } + /** * This creates a DATETIME waitpoint, that will be completed automatically when the specified date is reached. * If you pass an `idempotencyKey`, the waitpoint will be created only if it doesn't already exist. diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index 374d224aff..dcedf121ec 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -184,6 +184,76 @@ export class RunQueue { return this.redis.scard(this.keys.currentConcurrencyKey(env, queue, concurrencyKey)); } + public async currentConcurrencyOfQueues( + env: MinimalAuthenticatedEnvironment, + queues: string[] + ): Promise> { + const pipeline = this.redis.pipeline(); + + // Queue up all SCARD commands in the pipeline + queues.forEach((queue) => { + pipeline.scard(this.keys.currentConcurrencyKey(env, queue)); + }); + + // Execute pipeline and get results + const results = await pipeline.exec(); + + // If results is null, return all queues with 0 concurrency + if (!results) { + return queues.reduce( + (acc, queue) => { + acc[queue] = 0; + return acc; + }, + {} as Record + ); + } + + // Map results back to queue names, handling potential errors + return queues.reduce( + (acc, queue, index) => { + const [err, value] = results[index]; + // If there was an error or value is null/undefined, use 0 + acc[queue] = err || value == null ? 0 : (value as number); + return acc; + }, + {} as Record + ); + } + + public async lengthOfQueues( + env: MinimalAuthenticatedEnvironment, + queues: string[] + ): Promise> { + const pipeline = this.redis.pipeline(); + + // Queue up all ZCARD commands in the pipeline + queues.forEach((queue) => { + pipeline.zcard(this.keys.queueKey(env, queue)); + }); + + const results = await pipeline.exec(); + + if (!results) { + return queues.reduce( + (acc, queue) => { + acc[queue] = 0; + return acc; + }, + {} as Record + ); + } + + return queues.reduce( + (acc, queue, index) => { + const [err, value] = results![index]; + acc[queue] = err || value == null ? 0 : (value as number); + return acc; + }, + {} as Record + ); + } + public async currentConcurrencyOfEnvironment(env: MinimalAuthenticatedEnvironment) { return this.redis.scard(this.keys.envCurrentConcurrencyKey(env)); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e92a92047b..845b6b4310 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ export * from "./types.js"; export * from "./utils.js"; export * from "./schemas/json.js"; +export * from "./version.js"; diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index a10171d0ed..668a5c34a6 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -18,11 +18,14 @@ import { EnvironmentVariableResponseBody, EnvironmentVariableValue, EnvironmentVariables, + ListQueueOptions, ListRunResponseItem, ListScheduleOptions, + QueueItem, ReplayRunResponse, RescheduleRunRequestBody, RetrieveBatchV2Response, + RetrieveQueueParam, RetrieveRunResponse, ScheduleObject, TaskRunExecutionResult, @@ -716,6 +719,76 @@ export class ApiClient { ); } + listQueues(options?: ListQueueOptions, requestOptions?: ZodFetchOptions) { + const searchParams = new URLSearchParams(); + + if (options?.page) { + searchParams.append("page", options.page.toString()); + } + + if (options?.perPage) { + searchParams.append("perPage", options.perPage.toString()); + } + + return zodfetchOffsetLimitPage( + QueueItem, + `${this.baseUrl}/api/v1/queues`, + { + page: options?.page, + limit: options?.perPage, + }, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + retrieveQueue(queue: RetrieveQueueParam, requestOptions?: ZodFetchOptions) { + const type = typeof queue === "string" ? "id" : queue.type; + const value = typeof queue === "string" ? queue : queue.name; + + // Explicitly encode slashes before encoding the rest of the string + const encodedValue = encodeURIComponent(value.replace(/\//g, "%2F")); + + return zodfetch( + QueueItem, + `${this.baseUrl}/api/v1/queues/${encodedValue}?type=${type}`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + pauseQueue( + queue: RetrieveQueueParam, + action: "pause" | "resume", + requestOptions?: ZodFetchOptions + ) { + const type = typeof queue === "string" ? "id" : queue.type; + const value = typeof queue === "string" ? queue : queue.name; + + // Explicitly encode slashes before encoding the rest of the string + const encodedValue = encodeURIComponent(value.replace(/\//g, "%2F")); + + return zodfetch( + QueueItem, + `${this.baseUrl}/api/v1/queues/${encodedValue}/pause`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify({ + type, + action, + }), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + subscribeToRun( runId: string, options?: { diff --git a/packages/core/src/v3/schemas/index.ts b/packages/core/src/v3/schemas/index.ts index 0f0c753122..c2b17a72b6 100644 --- a/packages/core/src/v3/schemas/index.ts +++ b/packages/core/src/v3/schemas/index.ts @@ -14,3 +14,4 @@ export * from "./runEngine.js"; export * from "./webhooks.js"; export * from "./checkpoints.js"; export * from "./warmStart.js"; +export * from "./queues.js"; diff --git a/packages/core/src/v3/schemas/queues.ts b/packages/core/src/v3/schemas/queues.ts new file mode 100644 index 0000000000..2b511eb44c --- /dev/null +++ b/packages/core/src/v3/schemas/queues.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +const queueTypes = ["task", "custom"] as const; + +/** + * The type of queue, either "task" or "custom" + * "task" are created automatically for each task. + * "custom" are created by you explicitly in your code. + * */ +export const QueueType = z.enum(queueTypes); +export type QueueType = z.infer; + +export const RetrieveQueueType = z.enum([...queueTypes, "id"]); +export type RetrieveQueueType = z.infer; + +export const QueueItem = z.object({ + /** The queue id, e.g. queue_12345 */ + id: z.string(), + /** The queue name */ + name: z.string(), + /** + * The queue type, either "task" or "custom" + * "task" are created automatically for each task. + * "custom" are created by you explicitly in your code. + * */ + type: QueueType, + /** The number of runs currently running */ + running: z.number(), + /** The number of runs currently queued */ + queued: z.number(), + /** The concurrency limit of the queue */ + concurrencyLimit: z.number().nullable(), + /** Whether the queue is paused. If it's paused, no new runs will be started. */ + paused: z.boolean(), +}); + +export type QueueItem = z.infer; + +export const ListQueueOptions = z.object({ + /** The page number */ + page: z.number().optional(), + /** The number of queues per page */ + perPage: z.number().optional(), +}); + +export type ListQueueOptions = z.infer; + +/** + * When retrieving a queue you can either use the queue id, + * or the type and name. + * + * @example + * + * ```ts + * // Use a queue id (they start with queue_ + * const q1 = await queues.retrieve("queue_12345"); + * + * // Or use the type and name + * // The default queue for your "my-task-id" + * const q2 = await queues.retrieve({ type: "task", name: "my-task-id"}); + * + * // The custom queue you defined in your code + * const q3 = await queues.retrieve({ type: "custom", name: "my-custom-queue" }); + * ``` + */ +export const RetrieveQueueParam = z.union([ + z.string(), + z.object({ + /** "task" or "custom" */ + type: QueueType, + /** The name of your queue. + * For "task" type it will be the task id, for "custom" it will be the name you specified. + * */ + name: z.string(), + }), +]); + +export type RetrieveQueueParam = z.infer; diff --git a/packages/react-hooks/src/package.json b/packages/react-hooks/src/package.json deleted file mode 100644 index 5bbefffbab..0000000000 --- a/packages/react-hooks/src/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index f83254b8c9..5f00a4a3e1 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -48,6 +48,7 @@ export { } from "./runs.js"; export * as schedules from "./schedules/index.js"; export * as envvars from "./envvars.js"; +export * as queues from "./queues.js"; export type { ImportEnvironmentVariablesParams } from "./envvars.js"; export { configure, auth } from "./auth.js"; diff --git a/packages/trigger-sdk/src/v3/queues.ts b/packages/trigger-sdk/src/v3/queues.ts new file mode 100644 index 0000000000..6788186f9d --- /dev/null +++ b/packages/trigger-sdk/src/v3/queues.ts @@ -0,0 +1,177 @@ +import { + accessoryAttributes, + apiClientManager, + ApiPromise, + ApiRequestOptions, + flattenAttributes, + ListQueueOptions, + mergeRequestOptions, + OffsetLimitPagePromise, + QueueItem, + RetrieveQueueParam, +} from "@trigger.dev/core/v3"; +import { tracer } from "./tracer.js"; + +/** + * Lists queues + * @param options - The list options + * @param options.page - The page number + * @param options.perPage - The number of queues per page + * @returns The list of queues + */ +export function list( + options?: ListQueueOptions, + requestOptions?: ApiRequestOptions +): OffsetLimitPagePromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "queues.list()", + icon: "queue", + }, + requestOptions + ); + + return apiClient.listQueues(options, $requestOptions); +} + +/** + * When retrieving a queue you can either use the queue id, + * or the type and name. + * + * @example + * + * ```ts + * // Use a queue id (they start with queue_ + * const q1 = await queues.retrieve("queue_12345"); + * + * // Or use the type and name + * // The default queue for your "my-task-id" + * const q2 = await queues.retrieve({ type: "task", name: "my-task-id"}); + * + * // The custom queue you defined in your code + * const q3 = await queues.retrieve({ type: "custom", name: "my-custom-queue" }); + * ``` + * @param queue - The ID of the queue to retrieve, or the type and name + * @returns The retrieved queue + */ +export function retrieve( + queue: RetrieveQueueParam, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "queues.retrieve()", + icon: "queue", + attributes: { + ...flattenAttributes({ queue }), + ...accessoryAttributes({ + items: [ + { + text: typeof queue === "string" ? queue : queue.name, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + }, + requestOptions + ); + + return apiClient.retrieveQueue(queue, $requestOptions); +} + +/** + * Pauses a queue, preventing any new runs from being started. + * Runs that are currently running will continue to completion. + * + * @example + * ```ts + * // Pause using a queue id + * await queues.pause("queue_12345"); + * + * // Or pause using type and name + * await queues.pause({ type: "task", name: "my-task-id"}); + * ``` + * @param queue - The ID of the queue to pause, or the type and name + * @returns The updated queue state + */ +export function pause( + queue: RetrieveQueueParam, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "queues.pause()", + icon: "queue", + attributes: { + ...flattenAttributes({ queue }), + ...accessoryAttributes({ + items: [ + { + text: typeof queue === "string" ? queue : queue.name, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + }, + requestOptions + ); + + return apiClient.pauseQueue(queue, "pause", $requestOptions); +} + +/** + * Resumes a paused queue, allowing new runs to be started. + * + * @example + * ```ts + * // Resume using a queue id + * await queues.resume("queue_12345"); + * + * // Or resume using type and name + * await queues.resume({ type: "task", name: "my-task-id"}); + * ``` + * @param queue - The ID of the queue to resume, or the type and name + * @returns The updated queue state + */ +export function resume( + queue: RetrieveQueueParam, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "queues.resume()", + icon: "queue", + attributes: { + ...flattenAttributes({ queue }), + ...accessoryAttributes({ + items: [ + { + text: typeof queue === "string" ? queue : queue.name, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + }, + requestOptions + ); + + return apiClient.pauseQueue(queue, "resume", $requestOptions); +} diff --git a/references/hello-world/src/trigger/queues.ts b/references/hello-world/src/trigger/queues.ts new file mode 100644 index 0000000000..efca8fe0e5 --- /dev/null +++ b/references/hello-world/src/trigger/queues.ts @@ -0,0 +1,52 @@ +import { logger, queues, task } from "@trigger.dev/sdk/v3"; + +export const queuesTester = task({ + id: "queues-tester", + run: async (payload: any, { ctx }) => { + const q = await queues.list(); + + for await (const queue of q) { + logger.log("Queue", { queue }); + } + + const retrievedFromId = await queues.retrieve(ctx.queue.id); + logger.log("Retrieved from ID", { retrievedFromId }); + + const retrievedFromCtxName = await queues.retrieve({ + type: "task", + name: ctx.queue.name, + }); + logger.log("Retrieved from name", { retrievedFromCtxName }); + + //pause the queue + const pausedQueue = await queues.pause({ + type: "task", + name: "queues-tester", + }); + logger.log("Paused queue", { pausedQueue }); + + const retrievedFromName = await queues.retrieve({ + type: "task", + name: "queues-tester", + }); + logger.log("Retrieved from name", { retrievedFromName }); + + //resume the queue + const resumedQueue = await queues.resume({ + type: "task", + name: "queues-tester", + }); + logger.log("Resumed queue", { resumedQueue }); + }, +}); + +export const otherQueueTask = task({ + id: "other-queue-task", + queue: { + name: "my-custom-queue", + concurrencyLimit: 1, + }, + run: async (payload: any, { ctx }) => { + logger.log("Other queue task", { payload }); + }, +});