Filter runs by queue, machine, version (#2277)

* Queue in run table and filtering

* Debounce the filter changes

* Remove console log

* Added machine filtering

* Added version filtering

* Filter by version in the db

* Removed duplicate classes

* Version filtering hasFilters consistency

* Added queues and machines to the bulk action summary

* runs.list filtering for queue and machine

* Fix for machine errors
This commit is contained in:
Matt Aitken
2025-07-21 16:21:46 +01:00
committed by GitHub
parent ff018718f7
commit a90b73c7ca
19 changed files with 906 additions and 35 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Added runs.list filtering for queue and machine
+1 -1
View File
@@ -27,7 +27,7 @@ export function MachineIcon({ preset, className }: { preset?: string; className?
}
}
function MachineDefaultIcon({ className }: { className?: string }) {
export function MachineDefaultIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@@ -201,6 +201,32 @@ export function BulkActionFilterSummary({
/>
);
}
case "queues": {
const values = Array.isArray(value) ? value : [`${value}`];
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={filterTitle(key)}
icon={filterIcon(key)}
value={appliedSummary(values.map((v) => v.replace("task/", "")))}
removable={false}
/>
);
}
case "machines": {
const values = Array.isArray(value) ? value : [`${value}`];
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={filterTitle(key)}
icon={filterIcon(key)}
value={appliedSummary(values)}
removable={false}
/>
);
}
default: {
assertNever(typedKey);
}
@@ -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,
@@ -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<typeof TaskRunListSearchFilters>;
@@ -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 <StatusIcon className="size-4" />;
return <StatusIcon className="size-4 border-text-bright" />;
case "tasks":
return <TaskIcon className="size-4" />;
case "tags":
@@ -170,6 +213,12 @@ export function filterIcon(filterKey: string): ReactNode | undefined {
return <FingerPrintIcon className="size-4" />;
case "scheduleId":
return <ClockIcon className="size-4" />;
case "queues":
return <RectangleStackIcon className="size-4" />;
case "machines":
return <MachineDefaultIcon className="size-4" />;
case "versions":
return <IconRotateClockwise2 className="size-4" />;
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 (
<div className="flex flex-row flex-wrap items-center gap-1">
@@ -261,10 +325,13 @@ const filterTypes = [
{
name: "statuses",
title: "Status",
icon: <StatusIcon className="size-4" />,
icon: <StatusIcon className="size-4 border-text-bright" />,
},
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
{ name: "versions", title: "Versions", icon: <IconRotateClockwise2 className="size-4" /> },
{ name: "queues", title: "Queues", icon: <RectangleStackIcon className="size-4" /> },
{ name: "machines", title: "Machines", icon: <MachineDefaultIcon className="size-4" /> },
{ name: "run", title: "Run ID", icon: <FingerPrintIcon className="size-4" /> },
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
@@ -315,6 +382,9 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
<AppliedStatusFilter />
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedTagsFilter />
<AppliedVersionsFilter />
<AppliedQueuesFilter />
<AppliedMachinesFilter />
<AppliedRunIdFilter />
<AppliedBatchIdFilter />
<AppliedScheduleIdFilter />
@@ -343,12 +413,18 @@ function Menu(props: MenuProps) {
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "queues":
return <QueuesDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "machines":
return <MachinesDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "run":
return <RunIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "batch":
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "schedule":
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "versions":
return <VersionsDropdown onClose={() => 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<typeof queuesLoader>();
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 (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by queues..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((queue) => (
<SelectItem
key={queue.value}
value={queue.value}
icon={
queue.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
{queue.name}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No queues found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedQueuesFilter() {
const { values, del } = useSearchParams();
const queues = values("queues");
if (queues.length === 0 || queues.every((v) => v === "")) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Queues"
icon={filterIcon("queues")}
value={appliedSummary(values("queues").map((v) => v.replace("task/", "")))}
onRemove={() => del(["queues", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
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 (
<SelectProvider value={values("machines")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by machine..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item}
value={item}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<MachineLabelCombo preset={item} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedMachinesFilter() {
const { values, del } = useSearchParams();
const machines = values("machines");
if (machines.length === 0 || machines.every((v) => v === "")) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<MachinesDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Machines"
icon={filterIcon("machines")}
value={appliedSummary(
machines.map((v) => {
const parsed = MachinePresetName.safeParse(v);
if (!parsed.success) {
return v;
}
return formatMachinePresetName(parsed.data);
})
)}
onRemove={() => del(["machines", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
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<typeof versionsLoader>();
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 (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by versions..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((version) => (
<SelectItem key={version.version} value={version.version}>
{version.version}{" "}
{version.isCurrent ? <Badge variant="extra-small">current</Badge> : null}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No versions found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedVersionsFilter() {
const { values, del } = useSearchParams();
const versions = values("versions");
if (versions.length === 0 || versions.every((v) => v === "")) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<VersionsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Versions"
icon={filterIcon("versions")}
value={appliedSummary(values("versions"))}
onRemove={() => del(["versions", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
const { value, values, replace } = useSearchParams();
const searchValue = value("rootOnly");
@@ -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({
<TableHeaderCell className="pl-4" tooltip={<MachineTooltipInfo />}>
Machine
</TableHeaderCell>
<TableHeaderCell>Queue</TableHeaderCell>
<TableHeaderCell>Test</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
<TableHeaderCell
@@ -388,6 +384,22 @@ export function TaskRunsTable({
<TableCell to={path}>
<MachineLabelCombo preset={run.machinePreset} />
</TableCell>
<TableCell to={path}>
<span className="flex items-center gap-1">
{run.queue.type === "task" ? (
<SimpleTooltip
button={<TaskIconSmall className="size-[1.125rem] text-blue-500" />}
content={`This queue was automatically created from your "${run.queue.name}" task`}
/>
) : (
<SimpleTooltip
button={<RectangleStackIcon className="size-[1.125rem] text-purple-500" />}
content={`This is a custom queue you added in your code.`}
/>
)}
<span>{run.queue.name}</span>
</span>
</TableCell>
<TableCell to={path}>
{run.isTest ? <CheckIcon className="size-4 text-charcoal-400" /> : ""}
</TableCell>
+23 -1
View File
@@ -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<T extends (...args: any[]) => 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<T>(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
}
@@ -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<Pick<ParsedRunFilters, "rootOnly">>;
export async function getRunFiltersFromRequest(request: Request): Promise<FiltersFromRequest> {
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,
};
}
@@ -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<typeof ApiRunListSearchParams>;
@@ -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: {
@@ -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,
};
}
}
@@ -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,
};
}
@@ -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";
@@ -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<typeof RunListInputOptionsSchema>;
@@ -44,6 +47,11 @@ export type RunListInputFilters = Omit<
"organizationId" | "projectId" | "environmentId"
>;
export type ParsedRunFilters = RunListInputFilters & {
cursor?: string;
direction?: "forward" | "backward";
};
type FilterRunsOptions = Omit<RunListInputOptions, "period"> & {
period: number | undefined;
};
@@ -170,6 +178,7 @@ export class RunsRepository {
metadata: true,
metadataType: true,
machinePreset: true,
queue: true,
},
});
@@ -353,6 +362,16 @@ function applyRunFiltersToQueryBuilder<T>(
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 {
+46
View File
@@ -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",
],
},
],
},
});
+25
View File
@@ -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 {
+27 -1
View File
@@ -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> | QueueTypeName;
/** The machine name, or multiple of them. */
machine?: Array<MachinePresetName> | MachinePresetName;
}
export interface ListProjectRunsQueryParams extends CursorPageParams, ListRunsQueryParams {
+12 -11
View File
@@ -45,6 +45,17 @@ export const ListQueueOptions = z.object({
export type ListQueueOptions = z.infer<typeof ListQueueOptions>;
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<typeof QueueTypeName>;
/**
* 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<typeof ListQueueOptions>;
* 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<typeof RetrieveQueueParam>;
+14
View File
@@ -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;
},
});