Skip to content

Commit 3bccaa9

Browse files
committed
Added version filtering
1 parent b50de50 commit 3bccaa9

File tree

4 files changed

+292
-6
lines changed

4 files changed

+292
-6
lines changed

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
XMarkIcon,
1010
} from "@heroicons/react/20/solid";
1111
import { Form, useFetcher } from "@remix-run/react";
12-
import { IconToggleLeft } from "@tabler/icons-react";
12+
import { IconToggleLeft, IconRotateClockwise2 } from "@tabler/icons-react";
1313
import { MachinePresetName } from "@trigger.dev/core/v3";
1414
import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
1515
import { ListFilterIcon } from "lucide-react";
@@ -57,6 +57,7 @@ import { useProject } from "~/hooks/useProject";
5757
import { useSearchParams } from "~/hooks/useSearchParam";
5858
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
5959
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
60+
import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
6061
import { Button } from "../../primitives/Buttons";
6162
import { BulkActionTypeCombo } from "./BulkAction";
6263
import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters";
@@ -68,6 +69,7 @@ import {
6869
TaskRunStatusCombo,
6970
} from "./TaskRunStatus";
7071
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
72+
import { Badge } from "~/components/primitives/Badge";
7173

7274
export const RunStatus = z.enum(allTaskRunStatuses);
7375

@@ -177,6 +179,8 @@ export function filterTitle(filterKey: string) {
177179
return "Queues";
178180
case "machines":
179181
return "Machine";
182+
case "versions":
183+
return "Version";
180184
default:
181185
return filterKey;
182186
}
@@ -213,6 +217,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined {
213217
return <RectangleStackIcon className="size-4" />;
214218
case "machines":
215219
return <MachineDefaultIcon className="size-4" />;
220+
case "versions":
221+
return <IconRotateClockwise2 className="size-4" />;
216222
default:
217223
return undefined;
218224
}
@@ -255,6 +261,10 @@ export function getRunFiltersFromSearchParams(
255261
searchParams.getAll("machines").filter((v) => v.length > 0).length > 0
256262
? searchParams.getAll("machines")
257263
: undefined,
264+
versions:
265+
searchParams.getAll("versions").filter((v) => v.length > 0).length > 0
266+
? searchParams.getAll("versions")
267+
: undefined,
258268
};
259269

260270
const parsed = TaskRunListSearchFilters.safeParse(params);
@@ -290,7 +300,8 @@ export function RunsFilters(props: RunFiltersProps) {
290300
searchParams.has("runId") ||
291301
searchParams.has("scheduleId") ||
292302
searchParams.has("queues") ||
293-
searchParams.has("machines");
303+
searchParams.has("machines") ||
304+
searchParams.has("versions");
294305

295306
return (
296307
<div className="flex flex-row flex-wrap items-center gap-1">
@@ -318,6 +329,7 @@ const filterTypes = [
318329
},
319330
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
320331
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
332+
{ name: "versions", title: "Versions", icon: <IconRotateClockwise2 className="size-4" /> },
321333
{ name: "queues", title: "Queues", icon: <RectangleStackIcon className="size-4" /> },
322334
{ name: "machines", title: "Machines", icon: <MachineDefaultIcon className="size-4" /> },
323335
{ name: "run", title: "Run ID", icon: <FingerPrintIcon className="size-4" /> },
@@ -370,6 +382,7 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
370382
<AppliedStatusFilter />
371383
<AppliedTaskFilter possibleTasks={possibleTasks} />
372384
<AppliedTagsFilter />
385+
<AppliedVersionsFilter />
373386
<AppliedQueuesFilter />
374387
<AppliedMachinesFilter />
375388
<AppliedRunIdFilter />
@@ -410,6 +423,8 @@ function Menu(props: MenuProps) {
410423
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
411424
case "schedule":
412425
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
426+
case "versions":
427+
return <VersionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
413428
}
414429
}
415430

@@ -1130,6 +1145,153 @@ function AppliedMachinesFilter() {
11301145
);
11311146
}
11321147

1148+
function VersionsDropdown({
1149+
trigger,
1150+
clearSearchValue,
1151+
searchValue,
1152+
onClose,
1153+
}: {
1154+
trigger: ReactNode;
1155+
clearSearchValue: () => void;
1156+
searchValue: string;
1157+
onClose?: () => void;
1158+
}) {
1159+
const organization = useOrganization();
1160+
const project = useProject();
1161+
const environment = useEnvironment();
1162+
const { values, replace } = useSearchParams();
1163+
1164+
const handleChange = (values: string[]) => {
1165+
clearSearchValue();
1166+
replace({
1167+
versions: values.length > 0 ? values : undefined,
1168+
cursor: undefined,
1169+
direction: undefined,
1170+
});
1171+
};
1172+
1173+
const versionValues = values("versions").filter((v) => v !== "");
1174+
const selected = versionValues.length > 0 ? versionValues : undefined;
1175+
1176+
const fetcher = useFetcher<typeof versionsLoader>();
1177+
1178+
useDebounceEffect(
1179+
searchValue,
1180+
(s) => {
1181+
const searchParams = new URLSearchParams();
1182+
if (searchValue) {
1183+
searchParams.set("query", encodeURIComponent(s));
1184+
}
1185+
fetcher.load(
1186+
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
1187+
environment.slug
1188+
}/versions?${searchParams.toString()}`
1189+
);
1190+
},
1191+
250
1192+
);
1193+
1194+
const filtered = useMemo(() => {
1195+
let items: { version: string; isCurrent: boolean }[] = [];
1196+
1197+
for (const version of selected ?? []) {
1198+
const versionItem = fetcher.data?.versions.find((v) => v.version === version);
1199+
if (!versionItem) {
1200+
items.push({
1201+
version,
1202+
isCurrent: false,
1203+
});
1204+
}
1205+
}
1206+
1207+
if (fetcher.data === undefined) {
1208+
return matchSorter(items, searchValue);
1209+
}
1210+
1211+
items.push(...fetcher.data.versions);
1212+
1213+
if (searchValue === "") {
1214+
return items;
1215+
}
1216+
1217+
return matchSorter(Array.from(new Set(items)), searchValue, {
1218+
keys: ["version"],
1219+
});
1220+
}, [searchValue, fetcher.data]);
1221+
1222+
return (
1223+
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
1224+
{trigger}
1225+
<SelectPopover
1226+
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
1227+
hideOnEscape={() => {
1228+
if (onClose) {
1229+
onClose();
1230+
return false;
1231+
}
1232+
1233+
return true;
1234+
}}
1235+
>
1236+
<ComboBox
1237+
value={searchValue}
1238+
render={(props) => (
1239+
<div className="flex items-center justify-stretch">
1240+
<input {...props} placeholder={"Filter by versions..."} />
1241+
{fetcher.state === "loading" && <Spinner color="muted" />}
1242+
</div>
1243+
)}
1244+
/>
1245+
<SelectList>
1246+
{filtered.length > 0
1247+
? filtered.map((version) => (
1248+
<SelectItem key={version.version} value={version.version}>
1249+
{version.version}{" "}
1250+
{version.isCurrent ? <Badge variant="extra-small">current</Badge> : null}
1251+
</SelectItem>
1252+
))
1253+
: null}
1254+
{filtered.length === 0 && fetcher.state !== "loading" && (
1255+
<SelectItem disabled>No versions found</SelectItem>
1256+
)}
1257+
</SelectList>
1258+
</SelectPopover>
1259+
</SelectProvider>
1260+
);
1261+
}
1262+
1263+
function AppliedVersionsFilter() {
1264+
const { values, del } = useSearchParams();
1265+
1266+
const versions = values("versions");
1267+
1268+
if (versions.length === 0 || versions.every((v) => v === "")) {
1269+
return null;
1270+
}
1271+
1272+
return (
1273+
<FilterMenuProvider>
1274+
{(search, setSearch) => (
1275+
<VersionsDropdown
1276+
trigger={
1277+
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
1278+
<AppliedFilter
1279+
label="Versions"
1280+
icon={filterIcon("versions")}
1281+
value={appliedSummary(values("versions"))}
1282+
onRemove={() => del(["versions", "cursor", "direction"])}
1283+
variant="secondary/small"
1284+
/>
1285+
</Ariakit.Select>
1286+
}
1287+
searchValue={search}
1288+
clearSearchValue={() => setSearch("")}
1289+
/>
1290+
)}
1291+
</FilterMenuProvider>
1292+
);
1293+
}
1294+
11331295
function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
11341296
const { value, values, replace } = useSearchParams();
11351297
const searchValue = value("rootOnly");
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
2+
import { BasePresenter } from "./basePresenter.server";
3+
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
4+
5+
const DEFAULT_ITEMS_PER_PAGE = 25;
6+
const MAX_ITEMS_PER_PAGE = 100;
7+
8+
export class VersionListPresenter extends BasePresenter {
9+
private readonly perPage: number;
10+
11+
constructor(perPage: number = DEFAULT_ITEMS_PER_PAGE) {
12+
super();
13+
this.perPage = Math.min(perPage, MAX_ITEMS_PER_PAGE);
14+
}
15+
16+
public async call({
17+
environment,
18+
query,
19+
}: {
20+
environment: AuthenticatedEnvironment;
21+
query?: string;
22+
}) {
23+
const hasFilters = query !== undefined && query.length > 0;
24+
25+
const versions = await this._replica.backgroundWorker.findMany({
26+
select: {
27+
version: true,
28+
},
29+
where: {
30+
runtimeEnvironmentId: environment.id,
31+
},
32+
orderBy: {
33+
createdAt: "desc",
34+
},
35+
take: this.perPage,
36+
});
37+
38+
let currentVersion: string | undefined;
39+
40+
if (environment.type !== "DEVELOPMENT") {
41+
const currentWorker = await this._replica.workerDeploymentPromotion.findFirst({
42+
select: {
43+
deployment: {
44+
select: {
45+
version: true,
46+
},
47+
},
48+
},
49+
where: {
50+
environmentId: environment.id,
51+
label: CURRENT_DEPLOYMENT_LABEL,
52+
},
53+
});
54+
55+
if (currentWorker) {
56+
currentVersion = currentWorker.deployment.version;
57+
}
58+
}
59+
60+
return {
61+
success: true as const,
62+
versions: versions.map((version) => ({
63+
version: version.version,
64+
isCurrent: version.version === currentVersion,
65+
})),
66+
hasFilters,
67+
};
68+
}
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { findProjectBySlug } from "~/models/project.server";
4+
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
5+
import { VersionListPresenter } from "~/presenters/v3/VersionListPresenter.server";
6+
import { requireUserId } from "~/services/session.server";
7+
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
8+
9+
const SearchParamsSchema = z.object({
10+
query: z.string().optional(),
11+
per_page: z.coerce.number().min(1).default(25),
12+
});
13+
14+
export async function loader({ request, params }: LoaderFunctionArgs) {
15+
const userId = await requireUserId(request);
16+
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
17+
18+
const url = new URL(request.url);
19+
const { per_page, query } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
20+
21+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
22+
if (!project) {
23+
throw new Response(undefined, {
24+
status: 404,
25+
statusText: "Project not found",
26+
});
27+
}
28+
29+
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
30+
if (!environment) {
31+
throw new Response(undefined, {
32+
status: 404,
33+
statusText: "Environment not found",
34+
});
35+
}
36+
37+
const presenter = new VersionListPresenter(per_page);
38+
39+
const result = await presenter.call({
40+
environment: environment,
41+
query,
42+
});
43+
44+
if (!result.success) {
45+
return {
46+
versions: [],
47+
hasFilters: Boolean(query?.trim()),
48+
};
49+
}
50+
51+
return {
52+
versions: result.versions,
53+
hasFilters: result.hasFilters,
54+
};
55+
}

apps/webapp/app/services/runsRepository.server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { type ClickHouse, type ClickhouseQueryBuilder } from "@internal/clickhouse";
22
import { type Tracer } from "@internal/tracing";
33
import { type Logger, type LogLevel } from "@trigger.dev/core/logger";
4-
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
4+
import { MachinePresetName } from "@trigger.dev/core/v3";
5+
import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic";
6+
import { TaskRunStatus } from "@trigger.dev/database";
57
import parseDuration from "parse-duration";
8+
import { z } from "zod";
69
import { timeFilters } from "~/components/runs/v3/SharedFilters";
710
import { type PrismaClient } from "~/db.server";
8-
import { z } from "zod";
9-
import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic";
10-
import { MachinePresetName } from "@trigger.dev/core/v3";
1111

1212
export type RunsRepositoryOptions = {
1313
clickhouse: ClickHouse;

0 commit comments

Comments
 (0)