diff --git a/.changeset/two-eagles-report.md b/.changeset/two-eagles-report.md new file mode 100644 index 0000000000..11f034ed3f --- /dev/null +++ b/.changeset/two-eagles-report.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Added runs.list filtering for queue and machine diff --git a/apps/webapp/app/assets/icons/MachineIcon.tsx b/apps/webapp/app/assets/icons/MachineIcon.tsx index a58023a283..f07e7467b0 100644 --- a/apps/webapp/app/assets/icons/MachineIcon.tsx +++ b/apps/webapp/app/assets/icons/MachineIcon.tsx @@ -27,7 +27,7 @@ export function MachineIcon({ preset, className }: { preset?: string; className? } } -function MachineDefaultIcon({ className }: { className?: string }) { +export function MachineDefaultIcon({ className }: { className?: string }) { return ( ); } + case "queues": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + v.replace("task/", "")))} + removable={false} + /> + ); + } + case "machines": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } default: { assertNever(typedKey); } diff --git a/apps/webapp/app/components/MachineLabelCombo.tsx b/apps/webapp/app/components/MachineLabelCombo.tsx index 40a37e59a6..3d22ca527d 100644 --- a/apps/webapp/app/components/MachineLabelCombo.tsx +++ b/apps/webapp/app/components/MachineLabelCombo.tsx @@ -1,7 +1,9 @@ -import { type MachinePresetName } from "@trigger.dev/core/v3"; +import { MachinePresetName } from "@trigger.dev/core/v3"; import { MachineIcon } from "~/assets/icons/MachineIcon"; import { cn } from "~/utils/cn"; +export const machines = Object.values(MachinePresetName.enum); + export function MachineLabelCombo({ preset, className, diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 403690aa11..a44cd808a3 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -3,20 +3,28 @@ import { CalendarIcon, ClockIcon, FingerPrintIcon, + RectangleStackIcon, Squares2X2Icon, TagIcon, XMarkIcon, } from "@heroicons/react/20/solid"; import { Form, useFetcher } from "@remix-run/react"; -import { IconToggleLeft } from "@tabler/icons-react"; +import { IconToggleLeft, IconRotateClockwise2 } from "@tabler/icons-react"; +import { MachinePresetName } from "@trigger.dev/core/v3"; import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database"; import { ListFilterIcon } from "lucide-react"; import { matchSorter } from "match-sorter"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { z } from "zod"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { MachineDefaultIcon } from "~/assets/icons/MachineIcon"; import { StatusIcon } from "~/assets/icons/StatusIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; +import { + formatMachinePresetName, + MachineLabelCombo, + machines, +} from "~/components/MachineLabelCombo"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { DateTime } from "~/components/primitives/DateTime"; import { FormError } from "~/components/primitives/FormError"; @@ -41,10 +49,15 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/primitives/Tooltip"; +import { useDebounceEffect } from "~/hooks/useDebounce"; +import { useEnvironment } from "~/hooks/useEnvironment"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues"; import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags"; +import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions"; import { Button } from "../../primitives/Buttons"; import { BulkActionTypeCombo } from "./BulkAction"; import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters"; @@ -56,6 +69,7 @@ import { TaskRunStatusCombo, } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; +import { Badge } from "~/components/primitives/Badge"; export const RunStatus = z.enum(allTaskRunStatuses); @@ -75,6 +89,27 @@ const StringOrStringArray = z.preprocess((value) => { return undefined; }, z.string().array().optional()); +export const MachinePresetOrMachinePresetArray = z.preprocess((value) => { + if (typeof value === "string") { + if (value.length > 0) { + const parsed = MachinePresetName.safeParse(value); + return parsed.success ? [parsed.data] : undefined; + } + + return undefined; + } + + if (Array.isArray(value)) { + return value + .filter((v) => typeof v === "string" && v.length > 0) + .map((v) => MachinePresetName.safeParse(v)) + .filter((result) => result.success) + .map((result) => result.data); + } + + return undefined; +}, MachinePresetName.array().optional()); + export const TaskRunListSearchFilters = z.object({ cursor: z.string().optional(), direction: z.enum(["forward", "backward"]).optional(), @@ -105,6 +140,8 @@ export const TaskRunListSearchFilters = z.object({ batchId: z.string().optional(), runId: StringOrStringArray, scheduleId: z.string().optional(), + queues: StringOrStringArray, + machines: MachinePresetOrMachinePresetArray, }); export type TaskRunListSearchFilters = z.infer; @@ -138,6 +175,12 @@ export function filterTitle(filterKey: string) { return "Run ID"; case "scheduleId": return "Schedule ID"; + case "queues": + return "Queues"; + case "machines": + return "Machine"; + case "versions": + return "Version"; default: return filterKey; } @@ -149,7 +192,7 @@ export function filterIcon(filterKey: string): ReactNode | undefined { case "direction": return undefined; case "statuses": - return ; + return ; case "tasks": return ; case "tags": @@ -170,6 +213,12 @@ export function filterIcon(filterKey: string): ReactNode | undefined { return ; case "scheduleId": return ; + case "queues": + return ; + case "machines": + return ; + case "versions": + return ; default: return undefined; } @@ -204,6 +253,18 @@ export function getRunFiltersFromSearchParams( : undefined, batchId: searchParams.get("batchId") ?? undefined, scheduleId: searchParams.get("scheduleId") ?? undefined, + queues: + searchParams.getAll("queues").filter((v) => v.length > 0).length > 0 + ? searchParams.getAll("queues") + : undefined, + machines: + searchParams.getAll("machines").filter((v) => v.length > 0).length > 0 + ? searchParams.getAll("machines") + : undefined, + versions: + searchParams.getAll("versions").filter((v) => v.length > 0).length > 0 + ? searchParams.getAll("versions") + : undefined, }; const parsed = TaskRunListSearchFilters.safeParse(params); @@ -237,7 +298,10 @@ export function RunsFilters(props: RunFiltersProps) { searchParams.has("tags") || searchParams.has("batchId") || searchParams.has("runId") || - searchParams.has("scheduleId"); + searchParams.has("scheduleId") || + searchParams.has("queues") || + searchParams.has("machines") || + searchParams.has("versions"); return (
@@ -261,10 +325,13 @@ const filterTypes = [ { name: "statuses", title: "Status", - icon: , + icon: , }, { name: "tasks", title: "Tasks", icon: }, { name: "tags", title: "Tags", icon: }, + { name: "versions", title: "Versions", icon: }, + { name: "queues", title: "Queues", icon: }, + { name: "machines", title: "Machines", icon: }, { name: "run", title: "Run ID", icon: }, { name: "batch", title: "Batch ID", icon: }, { name: "schedule", title: "Schedule ID", icon: }, @@ -315,6 +382,9 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) { + + + @@ -343,12 +413,18 @@ function Menu(props: MenuProps) { return props.setFilterType(undefined)} {...props} />; case "tags": return props.setFilterType(undefined)} {...props} />; + case "queues": + return props.setFilterType(undefined)} {...props} />; + case "machines": + return props.setFilterType(undefined)} {...props} />; case "run": return props.setFilterType(undefined)} {...props} />; case "batch": return props.setFilterType(undefined)} {...props} />; case "schedule": return props.setFilterType(undefined)} {...props} />; + case "versions": + return props.setFilterType(undefined)} {...props} />; } } @@ -806,6 +882,416 @@ function AppliedTagsFilter() { ); } +function QueuesDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ + queues: values.length > 0 ? values : undefined, + cursor: undefined, + direction: undefined, + }); + }; + + const queueValues = values("queues").filter((v) => v !== ""); + const selected = queueValues.length > 0 ? queueValues : undefined; + + const fetcher = useFetcher(); + + useDebounceEffect( + searchValue, + (s) => { + const searchParams = new URLSearchParams(); + searchParams.set("per_page", "25"); + if (searchValue) { + searchParams.set("query", encodeURIComponent(s)); + } + fetcher.load( + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${ + environment.slug + }/queues?${searchParams.toString()}` + ); + }, + 250 + ); + + const filtered = useMemo(() => { + let items: { name: string; type: "custom" | "task"; value: string }[] = []; + + for (const queueName of selected ?? []) { + const queueItem = fetcher.data?.queues.find((q) => q.name === queueName); + if (!queueItem) { + if (queueName.startsWith("task/")) { + items.push({ + name: queueName.replace("task/", ""), + type: "task", + value: queueName, + }); + } else { + items.push({ + name: queueName, + type: "custom", + value: queueName, + }); + } + } + } + + if (fetcher.data === undefined) { + return matchSorter(items, searchValue); + } + + items.push( + ...fetcher.data.queues.map((q) => ({ + name: q.name, + type: q.type, + value: q.type === "task" ? `task/${q.name}` : q.name, + })) + ); + + return matchSorter(Array.from(new Set(items)), searchValue, { + keys: ["name"], + }); + }, [searchValue, fetcher.data]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + ( +
+ + {fetcher.state === "loading" && } +
+ )} + /> + + {filtered.length > 0 + ? filtered.map((queue) => ( + + ) : ( + + ) + } + > + {queue.name} + + )) + : null} + {filtered.length === 0 && fetcher.state !== "loading" && ( + No queues found + )} + +
+
+ ); +} + +function AppliedQueuesFilter() { + const { values, del } = useSearchParams(); + + const queues = values("queues"); + + if (queues.length === 0 || queues.every((v) => v === "")) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + v.replace("task/", "")))} + onRemove={() => del(["queues", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function MachinesDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ machines: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + if (searchValue === "") { + return machines; + } + return matchSorter(machines, searchValue); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item, index) => ( + + + + ))} + + + + ); +} + +function AppliedMachinesFilter() { + const { values, del } = useSearchParams(); + const machines = values("machines"); + + if (machines.length === 0 || machines.every((v) => v === "")) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + { + const parsed = MachinePresetName.safeParse(v); + if (!parsed.success) { + return v; + } + return formatMachinePresetName(parsed.data); + }) + )} + onRemove={() => del(["machines", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function VersionsDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ + versions: values.length > 0 ? values : undefined, + cursor: undefined, + direction: undefined, + }); + }; + + const versionValues = values("versions").filter((v) => v !== ""); + const selected = versionValues.length > 0 ? versionValues : undefined; + + const fetcher = useFetcher(); + + useDebounceEffect( + searchValue, + (s) => { + const searchParams = new URLSearchParams(); + if (searchValue) { + searchParams.set("query", encodeURIComponent(s)); + } + fetcher.load( + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${ + environment.slug + }/versions?${searchParams.toString()}` + ); + }, + 250 + ); + + const filtered = useMemo(() => { + let items: { version: string; isCurrent: boolean }[] = []; + + for (const version of selected ?? []) { + const versionItem = fetcher.data?.versions.find((v) => v.version === version); + if (!versionItem) { + items.push({ + version, + isCurrent: false, + }); + } + } + + if (fetcher.data === undefined) { + return matchSorter(items, searchValue); + } + + items.push(...fetcher.data.versions); + + if (searchValue === "") { + return items; + } + + return matchSorter(Array.from(new Set(items)), searchValue, { + keys: ["version"], + }); + }, [searchValue, fetcher.data]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + ( +
+ + {fetcher.state === "loading" && } +
+ )} + /> + + {filtered.length > 0 + ? filtered.map((version) => ( + + {version.version}{" "} + {version.isCurrent ? current : null} + + )) + : null} + {filtered.length === 0 && fetcher.state !== "loading" && ( + No versions found + )} + +
+
+ ); +} + +function AppliedVersionsFilter() { + const { values, del } = useSearchParams(); + + const versions = values("versions"); + + if (versions.length === 0 || versions.every((v) => v === "")) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + del(["versions", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) { const { value, values, replace } = useSearchParams(); const searchValue = value("rootOnly"); diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 4a76cd18dd..8abca02eef 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -8,12 +8,11 @@ import { } from "@heroicons/react/20/solid"; import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid"; import { useLocation } from "@remix-run/react"; -import { - formatDuration, - formatDurationMilliseconds, - MachinePresetName, -} from "@trigger.dev/core/v3"; +import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3"; import { useCallback, useRef } from "react"; +import { TaskIconSmall } from "~/assets/icons/TaskIcon"; +import { MachineLabelCombo } from "~/components/MachineLabelCombo"; +import { MachineTooltipInfo } from "~/components/MachineTooltipInfo"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Checkbox } from "~/components/primitives/Checkbox"; @@ -56,9 +55,6 @@ import { filterableTaskRunStatuses, TaskRunStatusCombo, } from "./TaskRunStatus"; -import { MachineIcon } from "~/assets/icons/MachineIcon"; -import { MachineLabelCombo } from "~/components/MachineLabelCombo"; -import { MachineTooltipInfo } from "~/components/MachineTooltipInfo"; type RunsTableProps = { total: number; @@ -82,9 +78,8 @@ export function TaskRunsTable({ }: RunsTableProps) { const organization = useOrganization(); const project = useProject(); - const environment = useEnvironment(); const checkboxes = useRef<(HTMLInputElement | null)[]>([]); - const { selectedItems, has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); + const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); const { isManagedCloud } = useFeatures(); const showCompute = isManagedCloud; @@ -211,6 +206,7 @@ export function TaskRunsTable({ }> Machine + Queue Test Created at + + + {run.queue.type === "task" ? ( + } + content={`This queue was automatically created from your "${run.queue.name}" task`} + /> + ) : ( + } + content={`This is a custom queue you added in your code.`} + /> + )} + {run.queue.name} + + {run.isTest ? : "–"} diff --git a/apps/webapp/app/hooks/useDebounce.ts b/apps/webapp/app/hooks/useDebounce.ts index a8670caf7f..da63330f2a 100644 --- a/apps/webapp/app/hooks/useDebounce.ts +++ b/apps/webapp/app/hooks/useDebounce.ts @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useEffect, useRef } from "react"; /** * A function that you call with a debounce delay, the function will only be called after the delay has passed @@ -19,3 +19,25 @@ export function useDebounce any>(fn: T, delay: num }, delay); }; } + +/** + * A function that takes in a value, function, and delay. + * It will run the function with the debounced value, only if the value has changed. + * It should deal with the function being passed in not being a useCallback + */ +export function useDebounceEffect(value: T, fn: (value: T) => void, delay: number) { + const fnRef = useRef(fn); + + // Update the ref whenever the function changes + fnRef.current = fn; + + useEffect(() => { + const timeout = setTimeout(() => { + fnRef.current(value); + }, delay); + + return () => { + clearTimeout(timeout); + }; + }, [value, delay]); // Only depend on value and delay, not fn +} diff --git a/apps/webapp/app/presenters/RunFilters.server.ts b/apps/webapp/app/presenters/RunFilters.server.ts index 37a5a4755b..db19e65656 100644 --- a/apps/webapp/app/presenters/RunFilters.server.ts +++ b/apps/webapp/app/presenters/RunFilters.server.ts @@ -3,8 +3,11 @@ import { TaskRunListSearchFilters, } from "~/components/runs/v3/RunFilters"; import { getRootOnlyFilterPreference } from "~/services/preferences/uiPreferences.server"; +import { type ParsedRunFilters } from "~/services/runsRepository.server"; -export async function getRunFiltersFromRequest(request: Request) { +type FiltersFromRequest = ParsedRunFilters & Required>; + +export async function getRunFiltersFromRequest(request: Request): Promise { const url = new URL(request.url); let rootOnlyValue = false; if (url.searchParams.has("rootOnly")) { @@ -29,6 +32,8 @@ export async function getRunFiltersFromRequest(request: Request) { runId, batchId, scheduleId, + queues, + machines, } = TaskRunListSearchFilters.parse(s); return { @@ -46,5 +51,7 @@ export async function getRunFiltersFromRequest(request: Request) { rootOnly: rootOnlyValue, direction: direction, cursor: cursor, + queues, + machines, }; } diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index b541f75a47..a2e44969bf 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -1,4 +1,10 @@ -import { parsePacket, RunStatus } from "@trigger.dev/core/v3"; +import { + type ListRunResponse, + type ListRunResponseItem, + MachinePresetName, + parsePacket, + RunStatus, +} from "@trigger.dev/core/v3"; import { type Project, type RuntimeEnvironment, type TaskRunStatus } from "@trigger.dev/database"; import assertNever from "assert-never"; import { z } from "zod"; @@ -104,6 +110,39 @@ export const ApiRunListSearchParams = z.object({ "filter[createdAt][to]": CoercedDate, "filter[createdAt][period]": z.string().optional(), "filter[batch]": z.string().optional(), + "filter[queue]": z + .string() + .optional() + .transform((value) => { + return value ? value.split(",") : undefined; + }), + "filter[machine]": z + .string() + .optional() + .transform((value, ctx) => { + const values = value ? value.split(",") : undefined; + if (!values) { + return undefined; + } + + const parsedValues = values.map((v) => MachinePresetName.safeParse(v)); + const invalidValues: string[] = []; + parsedValues.forEach((result, index) => { + if (!result.success) { + invalidValues.push(values[index]); + } + }); + if (invalidValues.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid machine values: ${invalidValues.join(", ")}`, + }); + + return z.NEVER; + } + + return parsedValues.map((result) => result.data).filter(Boolean); + }), }); type ApiRunListSearchParams = z.infer; diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index 9217c5039d..960bdfc10a 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -1,4 +1,5 @@ import { type ClickHouse } from "@internal/clickhouse"; +import { MachinePresetName } from "@trigger.dev/core/v3"; import { type PrismaClient, type PrismaClientOrTransaction, @@ -30,6 +31,8 @@ export type RunListOptions = { rootOnly?: boolean; batchId?: string; runId?: string[]; + queues?: string[]; + machines?: MachinePresetName[]; //pagination direction?: Direction; cursor?: string; @@ -65,6 +68,8 @@ export class NextRunListPresenter { rootOnly, batchId, runId, + queues, + machines, from, to, direction = "forward", @@ -90,6 +95,8 @@ export class NextRunListPresenter { (tags !== undefined && tags.length > 0) || batchId !== undefined || (runId !== undefined && runId.length > 0) || + (queues !== undefined && queues.length > 0) || + (machines !== undefined && machines.length > 0) || typeof isTest === "boolean" || rootOnly === true || !time.isDefault; @@ -173,6 +180,8 @@ export class NextRunListPresenter { batchId, runId, bulkId, + queues, + machines, page: { size: pageSize, cursor, @@ -233,6 +242,10 @@ export class NextRunListPresenter { metadata: run.metadata, metadataType: run.metadataType, machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined, + queue: { + name: run.queue.replace("task/", ""), + type: run.queue.startsWith("task/") ? "task" : "custom", + }, }; }), pagination: { diff --git a/apps/webapp/app/presenters/v3/VersionListPresenter.server.ts b/apps/webapp/app/presenters/v3/VersionListPresenter.server.ts new file mode 100644 index 0000000000..f8a4a36538 --- /dev/null +++ b/apps/webapp/app/presenters/v3/VersionListPresenter.server.ts @@ -0,0 +1,74 @@ +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { BasePresenter } from "./basePresenter.server"; +import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic"; + +const DEFAULT_ITEMS_PER_PAGE = 25; +const MAX_ITEMS_PER_PAGE = 100; + +export class VersionListPresenter 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, + query, + }: { + environment: AuthenticatedEnvironment; + query?: string; + }) { + const hasFilters = query !== undefined && query.length > 0; + + const versions = await this._replica.backgroundWorker.findMany({ + select: { + version: true, + }, + where: { + runtimeEnvironmentId: environment.id, + version: query + ? { + contains: query, + } + : undefined, + }, + orderBy: { + createdAt: "desc", + }, + take: this.perPage, + }); + + let currentVersion: string | undefined; + + if (environment.type !== "DEVELOPMENT") { + const currentWorker = await this._replica.workerDeploymentPromotion.findFirst({ + select: { + deployment: { + select: { + version: true, + }, + }, + }, + where: { + environmentId: environment.id, + label: CURRENT_DEPLOYMENT_LABEL, + }, + }); + + if (currentWorker) { + currentVersion = currentWorker.deployment.version; + } + } + + return { + success: true as const, + versions: versions.map((version) => ({ + version: version.version, + isCurrent: version.version === currentVersion, + })), + hasFilters, + }; + } +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions.ts new file mode 100644 index 0000000000..f17f2a95c8 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions.ts @@ -0,0 +1,55 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { VersionListPresenter } from "~/presenters/v3/VersionListPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; + +const SearchParamsSchema = z.object({ + query: z.string().optional(), + per_page: z.coerce.number().min(1).default(25), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const url = new URL(request.url); + const { per_page, query } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + const presenter = new VersionListPresenter(per_page); + + const result = await presenter.call({ + environment: environment, + query, + }); + + if (!result.success) { + return { + versions: [], + hasFilters: query !== undefined && query.length > 0, + }; + } + + return { + versions: result.versions, + hasFilters: result.hasFilters, + }; +} diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 928ace6592..40d0c87bf4 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -12,7 +12,6 @@ import { import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; import { MemoryStore } from "@unkey/cache/stores"; import { redirect } from "remix-typedjson"; -import { $replica } from "~/db.server"; import { env } from "~/env.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { createEnvironment } from "~/models/organization.server"; diff --git a/apps/webapp/app/services/runsRepository.server.ts b/apps/webapp/app/services/runsRepository.server.ts index acc3670206..3196c436b3 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository.server.ts @@ -1,12 +1,13 @@ import { type ClickHouse, type ClickhouseQueryBuilder } from "@internal/clickhouse"; import { type Tracer } from "@internal/tracing"; import { type Logger, type LogLevel } from "@trigger.dev/core/logger"; -import { Prisma, TaskRunStatus } from "@trigger.dev/database"; +import { MachinePresetName } from "@trigger.dev/core/v3"; +import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic"; +import { TaskRunStatus } from "@trigger.dev/database"; import parseDuration from "parse-duration"; +import { z } from "zod"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { type PrismaClient } from "~/db.server"; -import { z } from "zod"; -import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic"; export type RunsRepositoryOptions = { clickhouse: ClickHouse; @@ -36,6 +37,8 @@ const RunListInputOptionsSchema = z.object({ batchId: z.string().optional(), runId: z.array(z.string()).optional(), bulkId: z.string().optional(), + queues: z.array(z.string()).optional(), + machines: MachinePresetName.array().optional(), }); export type RunListInputOptions = z.infer; @@ -44,6 +47,11 @@ export type RunListInputFilters = Omit< "organizationId" | "projectId" | "environmentId" >; +export type ParsedRunFilters = RunListInputFilters & { + cursor?: string; + direction?: "forward" | "backward"; +}; + type FilterRunsOptions = Omit & { period: number | undefined; }; @@ -170,6 +178,7 @@ export class RunsRepository { metadata: true, metadataType: true, machinePreset: true, + queue: true, }, }); @@ -353,6 +362,16 @@ function applyRunFiltersToQueryBuilder( runIds: options.runId.map((runId) => RunId.toFriendlyId(runId)), }); } + + if (options.queues && options.queues.length > 0) { + queryBuilder.where("queue IN {queues: Array(String)}", { queues: options.queues }); + } + + if (options.machines && options.machines.length > 0) { + queryBuilder.where("machine_preset IN {machines: Array(String)}", { + machines: options.machines, + }); + } } export function parseRunListInputOptions(data: any): RunListInputOptions { diff --git a/apps/webapp/app/utils/cn.ts b/apps/webapp/app/utils/cn.ts index d33fe61b52..842542049d 100644 --- a/apps/webapp/app/utils/cn.ts +++ b/apps/webapp/app/utils/cn.ts @@ -25,6 +25,52 @@ const customTwMerge = extendTailwindMerge({ ], }, ], + size: [ + { + size: [ + "0", + "0.5", + "1", + "1.5", + "2", + "2.5", + "3", + "3.5", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "14", + "16", + "20", + "24", + "28", + "32", + "36", + "40", + "44", + "48", + "52", + "56", + "60", + "64", + "72", + "80", + "96", + "auto", + "px", + "full", + "min", + "max", + "fit", + ], + }, + ], }, }); diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index e230374974..4eab7d0089 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -21,6 +21,7 @@ import { ListRunResponseItem, ListScheduleOptions, QueueItem, + QueueTypeName, ReplayRunResponse, RescheduleRunRequestBody, RetrieveBatchV2Response, @@ -1147,11 +1148,35 @@ function createSearchQueryForListRuns(query?: ListRunsQueryParams): URLSearchPar if (query.batch) { searchParams.append("filter[batch]", query.batch); } + + if (query.queue) { + searchParams.append( + "filter[queue]", + Array.isArray(query.queue) + ? query.queue.map((q) => queueNameFromQueueTypeName(q)).join(",") + : queueNameFromQueueTypeName(query.queue) + ); + } + + if (query.machine) { + searchParams.append( + "filter[machine]", + Array.isArray(query.machine) ? query.machine.join(",") : query.machine + ); + } } return searchParams; } +function queueNameFromQueueTypeName(queue: QueueTypeName): string { + if (queue.type === "task") { + return `task/${queue.name}`; + } + + return queue.name; +} + function createSearchQueryForListWaitpointTokens( query?: ListWaitpointTokensQueryParams ): URLSearchParams { diff --git a/packages/core/src/v3/apiClient/types.ts b/packages/core/src/v3/apiClient/types.ts index 5715d881cc..79baaf74ef 100644 --- a/packages/core/src/v3/apiClient/types.ts +++ b/packages/core/src/v3/apiClient/types.ts @@ -1,4 +1,9 @@ -import { RunStatus, WaitpointTokenStatus } from "../schemas/index.js"; +import { + MachinePresetName, + QueueTypeName, + RunStatus, + WaitpointTokenStatus, +} from "../schemas/index.js"; import { CursorPageParams } from "./pagination.js"; export interface ImportEnvironmentVariablesParams { @@ -32,6 +37,27 @@ export interface ListRunsQueryParams extends CursorPageParams { schedule?: string; isTest?: boolean; batch?: string; + /** + * The queue type and name, or multiple of them. + * + * @example + * ```ts + * const runs = await runs.list({ + * queue: { type: "task", name: "my-task-id" }, + * }); + * + * // Or multiple queues + * const runs = await runs.list({ + * queue: [ + * { type: "custom", name: "my-custom-queue" }, + * { type: "task", name: "my-task-id" }, + * ], + * }); + * ``` + * */ + queue?: Array | QueueTypeName; + /** The machine name, or multiple of them. */ + machine?: Array | MachinePresetName; } export interface ListProjectRunsQueryParams extends CursorPageParams, ListRunsQueryParams { diff --git a/packages/core/src/v3/schemas/queues.ts b/packages/core/src/v3/schemas/queues.ts index 2b511eb44c..cf42f0ed9a 100644 --- a/packages/core/src/v3/schemas/queues.ts +++ b/packages/core/src/v3/schemas/queues.ts @@ -45,6 +45,17 @@ export const ListQueueOptions = z.object({ export type ListQueueOptions = z.infer; +export const QueueTypeName = 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 QueueTypeName = z.infer; + /** * When retrieving a queue you can either use the queue id, * or the type and name. @@ -63,16 +74,6 @@ export type ListQueueOptions = z.infer; * 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 const RetrieveQueueParam = z.union([z.string(), QueueTypeName]); export type RetrieveQueueParam = z.infer; diff --git a/references/hello-world/src/trigger/sdk.ts b/references/hello-world/src/trigger/sdk.ts index 9f714e4729..b124aa1452 100644 --- a/references/hello-world/src/trigger/sdk.ts +++ b/references/hello-world/src/trigger/sdk.ts @@ -20,6 +20,20 @@ export const sdkMethods = task({ logger.info("failed run", { run }); } + for await (const run of runs.list({ + queue: { type: "task", name: "sdk-methods" }, + limit: 5, + })) { + logger.info("sdk-methods run", { run }); + } + + for await (const run of runs.list({ + machine: ["small-1x", "small-2x"], + limit: 5, + })) { + logger.info("small machine run", { run }); + } + return runs; }, });