Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions apps/webapp/app/components/runs/v3/RunFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ 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 versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
import { Button } from "../../primitives/Buttons";
import { BulkActionTypeCombo } from "./BulkAction";
import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters";
import { appliedSummary, FilterMenuProvider, TimeFilter, timeFilters } from "./SharedFilters";
import { AIFilterInput } from "./AIFilterInput";
import {
allTaskRunStatuses,
Expand All @@ -71,6 +71,7 @@ import {
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { environment } from "effect/Differ";

export const RunStatus = z.enum(allTaskRunStatuses);

Expand Down Expand Up @@ -810,8 +811,8 @@ function TagsDropdown({
searchValue: string;
onClose?: () => void;
}) {
const project = useProject();
const { values, replace } = useSearchParams();
const environment = useEnvironment();
const { values, value, replace } = useSearchParams();

const handleChange = (values: string[]) => {
clearSearchValue();
Expand All @@ -822,6 +823,12 @@ function TagsDropdown({
});
};

const { period, from, to } = timeFilters({
period: value("period"),
from: value("from"),
to: value("to"),
});

const tagValues = values("tags").filter((v) => v !== "");
const selected = tagValues.length > 0 ? tagValues : undefined;

Expand All @@ -832,8 +839,17 @@ function TagsDropdown({
if (searchValue) {
searchParams.set("name", encodeURIComponent(searchValue));
}
fetcher.load(`/resources/projects/${project.slug}/runs/tags?${searchParams}`);
}, [searchValue]);
if (period) {
searchParams.set("period", period);
}
if (from) {
searchParams.set("from", from.getTime().toString());
}
if (to) {
searchParams.set("to", to.getTime().toString());
}
fetcher.load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
}, [searchValue, period, from?.getTime(), to?.getTime()]);

const filtered = useMemo(() => {
let items: string[] = [];
Expand All @@ -845,7 +861,7 @@ function TagsDropdown({
return matchSorter(items, searchValue);
}

items.push(...fetcher.data.tags.map((t) => t.name));
items.push(...fetcher.data.tags);

return matchSorter(Array.from(new Set(items)), searchValue);
}, [searchValue, fetcher.data]);
Expand Down
55 changes: 31 additions & 24 deletions apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { BasePresenter } from "./basePresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { type PrismaClient } from "@trigger.dev/database";
import { timeFilters } from "~/components/runs/v3/SharedFilters";

export type TagListOptions = {
userId?: string;
organizationId: string;
environmentId: string;
projectId: string;
period?: string;
from?: Date;
to?: Date;
//filters
name?: string;
//pagination
Expand All @@ -17,40 +25,39 @@ export type TagListItem = TagList["tags"][number];

export class RunTagListPresenter extends BasePresenter {
public async call({
userId,
organizationId,
environmentId,
projectId,
name,
period,
from,
to,
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
}: TagListOptions) {
const hasFilters = Boolean(name?.trim());

const tags = await this._replica.taskRunTag.findMany({
where: {
projectId,
name: name
? {
startsWith: name,
mode: "insensitive",
}
: undefined,
},
orderBy: {
id: "desc",
},
take: pageSize + 1,
skip: (page - 1) * pageSize,
const runsRepository = new RunsRepository({
clickhouse: clickhouseClient,
prisma: this._replica as PrismaClient,
});

const tags = await runsRepository.listTags({
organizationId,
projectId,
environmentId,
query: name,
period,
from: from ? from.getTime() : undefined,
to: to ? to.getTime() : undefined,
offset: (page - 1) * pageSize,
limit: pageSize + 1,
});

return {
tags: tags
.map((tag) => ({
id: tag.friendlyId,
name: tag.name,
}))
.slice(0, pageSize),
tags: tags.tags,
currentPage: page,
hasMore: tags.length > pageSize,
hasMore: tags.tags.length > pageSize,
hasFilters,
};
}
Expand Down
67 changes: 67 additions & 0 deletions apps/webapp/app/routes/resources.environments.$envId.runs.tags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { $replica } from "~/db.server";
import { RunTagListPresenter } from "~/presenters/v3/RunTagListPresenter.server";
import { requireUserId } from "~/services/session.server";

const Params = z.object({
envId: z.string(),
});

const SearchParams = z.object({
name: z.string().optional(),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});

export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { envId } = Params.parse(params);

const environment = await $replica.runtimeEnvironment.findFirst({
select: {
id: true,
projectId: true,
organizationId: true,
},
where: { id: envId, organization: { members: { some: { userId } } } },
});

if (!environment) {
throw new Response("Not Found", { status: 404 });
}

const search = new URL(request.url).searchParams;
const name = search.get("name");

const parsedSearchParams = SearchParams.safeParse({
name: name ? decodeURIComponent(name) : undefined,
period: search.get("period") ?? undefined,
from: search.get("from") ?? undefined,
to: search.get("to") ?? undefined,
});

if (!parsedSearchParams.success) {
throw new Response("Invalid search params", { status: 400 });
}

const { period, from, to } = timeFilters({
period: parsedSearchParams.data.period,
from: parsedSearchParams.data.from,
to: parsedSearchParams.data.to,
});

const presenter = new RunTagListPresenter();
const result = await presenter.call({
environmentId: environment.id,
projectId: environment.projectId,
organizationId: environment.organizationId,
name: parsedSearchParams.data.name,
period,
from,
to,
});
return result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
query: async (search) => {
const tagPresenter = new RunTagListPresenter();
const tags = await tagPresenter.call({
organizationId: environment.organizationId,
projectId: environment.projectId,
environmentId: environment.id,
name: search,
page: 1,
pageSize: 50,
period: "1y",
});
return {
tags: tags.tags.map((t) => t.name),
tags: tags.tags,
};
},
};
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
type ListRunsOptions,
type RunListInputOptions,
type RunsRepositoryOptions,
type TagListOptions,
convertRunListInputOptionsToFilterRunsOptions,
} from "./runsRepository.server";
import parseDuration from "parse-duration";

export class ClickHouseRunsRepository implements IRunsRepository {
constructor(private readonly options: RunsRepositoryOptions) {}
Expand Down Expand Up @@ -162,6 +164,53 @@ export class ClickHouseRunsRepository implements IRunsRepository {

return result[0].count;
}

async listTags(options: TagListOptions) {
const queryBuilder = this.options.clickhouse.taskRuns
.tagQueryBuilder()
.where("organization_id = {organizationId: String}", {
organizationId: options.organizationId,
})
.where("project_id = {projectId: String}", {
projectId: options.projectId,
})
.where("environment_id = {environmentId: String}", {
environmentId: options.environmentId,
});

const periodMs = options.period ? parseDuration(options.period) ?? undefined : undefined;
if (periodMs) {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", {
period: new Date(Date.now() - periodMs).getTime(),
});
}

if (options.from) {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({from: Int64})", {
from: options.from,
});
}

if (options.to) {
queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to });
}

const [queryError, result] = await queryBuilder.execute();

if (queryError) {
throw queryError;
}

if (result.length === 0) {
return {
tags: [],
};
}

return {
tags: result.flatMap((row) => row.tags),
};
}
Comment on lines +168 to +213
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Honor query and pagination when listing tags.

listTags ignores options.query, options.limit, and options.offset, so /runs/tags always scans everything and the name= filter from the presenter is a no-op. That’s a regression from the previous implementation and makes hasMore meaningless. You need to push the filtering and pagination into ClickHouse before executing the query. For example, apply the name filter:

     if (options.to) {
       queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to });
     }
+
+    if (options.query) {
+      queryBuilder.where(
+        "arrayExists(tag -> ilike(tag, {query: String}), tags)",
+        { query: `%${options.query}%` }
+      );
+    }
+
+    if (options.limit !== undefined) {
+      queryBuilder.limit(options.limit + 1, options.offset ?? 0);
+    }

Make sure the ClickHouse builder’s limit call (or equivalent) honours both limit and offset so the response aligns with the requested page.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async listTags(options: TagListOptions) {
const queryBuilder = this.options.clickhouse.taskRuns
.tagQueryBuilder()
.where("organization_id = {organizationId: String}", {
organizationId: options.organizationId,
})
.where("project_id = {projectId: String}", {
projectId: options.projectId,
})
.where("environment_id = {environmentId: String}", {
environmentId: options.environmentId,
});
const periodMs = options.period ? parseDuration(options.period) ?? undefined : undefined;
if (periodMs) {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", {
period: new Date(Date.now() - periodMs).getTime(),
});
}
if (options.from) {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({from: Int64})", {
from: options.from,
});
}
if (options.to) {
queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to });
}
const [queryError, result] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
if (result.length === 0) {
return {
tags: [],
};
}
return {
tags: result.flatMap((row) => row.tags),
};
}
async listTags(options: TagListOptions) {
const queryBuilder = this.options.clickhouse.taskRuns
.tagQueryBuilder()
.where("organization_id = {organizationId: String}", {
organizationId: options.organizationId,
})
.where("project_id = {projectId: String}", {
projectId: options.projectId,
})
.where("environment_id = {environmentId: String}", {
environmentId: options.environmentId,
});
const periodMs = options.period ? parseDuration(options.period) ?? undefined : undefined;
if (periodMs) {
queryBuilder.where(
"created_at >= fromUnixTimestamp64Milli({period: Int64})",
{
period: new Date(Date.now() - periodMs).getTime(),
}
);
}
if (options.from) {
queryBuilder.where(
"created_at >= fromUnixTimestamp64Milli({from: Int64})",
{ from: options.from }
);
}
if (options.to) {
queryBuilder.where(
"created_at <= fromUnixTimestamp64Milli({to: Int64})",
{ to: options.to }
);
}
if (options.query) {
queryBuilder.where(
"arrayExists(tag -> ilike(tag, {query: String}), tags)",
{ query: `%${options.query}%` }
);
}
if (options.limit !== undefined) {
// Fetch one extra to detect "hasMore" on the client
queryBuilder.limit(options.limit + 1, options.offset ?? 0);
}
const [queryError, result] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
if (result.length === 0) {
return {
tags: [],
};
}
return {
tags: result.flatMap((row) => row.tags),
};
}
🤖 Prompt for AI Agents
In apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts
around lines 168 to 213, listTags currently ignores options.query, options.limit
and options.offset so name filtering and pagination happen in JS instead of
ClickHouse; update the ClickHouse query builder to (1) apply the name filter
when options.query is present (e.g., add a where clause that filters tag
name/keys by the provided query string), (2) push pagination into the builder by
calling its limit/offset (or equivalent) using options.limit and options.offset,
and (3) when computing hasMore, fetch one extra row (limit + 1) from ClickHouse
and derive hasMore from that extra row, returning only the requested page of
tags; ensure the parameter types and placeholders match the builder API and
preserve existing organization/project/environment/time filters.

}

function applyRunFiltersToQueryBuilder<T>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ListedRun,
type RunListInputOptions,
type RunsRepositoryOptions,
type TagListOptions,
convertRunListInputOptionsToFilterRunsOptions,
} from "./runsRepository.server";

Expand Down Expand Up @@ -104,6 +105,32 @@ export class PostgresRunsRepository implements IRunsRepository {
return Number(result[0].count);
}

async listTags({ projectId, query, offset, limit }: TagListOptions) {
const tags = await this.options.prisma.taskRunTag.findMany({
select: {
name: true,
},
where: {
projectId,
name: query
? {
startsWith: query,
mode: "insensitive",
}
: undefined,
},
orderBy: {
id: "desc",
},
take: limit + 1,
skip: offset,
});

return {
tags: tags.map((tag) => tag.name),
};
}
Comment on lines +108 to +132
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical type mismatch and missing filter implementations.

The method signature destructures only { projectId, query, offset, limit } but TagListOptions (imported from runsRepository.server.ts at line 11) includes additional required fields: organizationId, environmentId, period, from, and to. This implementation:

  1. Ignores organizationId and environmentId filters entirely, which could return tags from wrong organizations/environments
  2. Ignores time-range filters (period, from, to), unlike the ClickHouse implementation
  3. Uses startsWith for query filtering but the TagListOptions type documents it as "case insensitive contains search"
  4. Fetches limit + 1 rows (line 125) but doesn't return pagination metadata like hasMore

Apply this diff to align with the interface contract:

-  async listTags({ projectId, query, offset, limit }: TagListOptions) {
+  async listTags({ organizationId, environmentId, projectId, query, period, from, to, offset, limit }: TagListOptions) {
+    // Build time filter conditions
+    const timeConditions: Prisma.TaskRunTagWhereInput = {};
+    
+    if (period) {
+      const periodMs = parseDuration(period);
+      if (periodMs) {
+        timeConditions.createdAt = {
+          gte: new Date(Date.now() - periodMs),
+        };
+      }
+    }
+    
+    if (from) {
+      timeConditions.createdAt = {
+        ...timeConditions.createdAt,
+        gte: new Date(from),
+      };
+    }
+    
+    if (to) {
+      timeConditions.createdAt = {
+        ...timeConditions.createdAt,
+        lte: new Date(to),
+      };
+    }
+
     const tags = await this.options.prisma.taskRunTag.findMany({
       select: {
         name: true,
       },
       where: {
+        project: {
+          organizationId,
+        },
         projectId,
+        taskRun: {
+          runtimeEnvironmentId: environmentId,
+        },
         name: query
           ? {
-              startsWith: query,
+              contains: query,
               mode: "insensitive",
             }
           : undefined,
+        ...timeConditions,
       },
       orderBy: {
         id: "desc",
       },
-      take: limit + 1,
+      take: limit,
       skip: offset,
     });

     return {
       tags: tags.map((tag) => tag.name),
     };
   }

Note: You'll need to import parseDuration from "parse-duration" at the top of the file if not already imported.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts
around lines 108–132, the listTags implementation only handles projectId, query,
offset, and limit but must also apply organizationId and environmentId filters,
honor time-range filters (period / from / to), use a case-insensitive "contains"
search for tag name, and return pagination metadata. Update the function
signature to destructure organizationId, environmentId, period, from, to;
compute an effective from/to range (if period is provided, compute from = now -
parseDuration(period)); add the where filters for organizationId and
environmentId; add createdAt: { gte: from, lte: to } when available; change name
filter to { contains: query, mode: "insensitive" } when query is present; keep
take: limit + 1 and determine hasMore = tags.length > limit, then slice to limit
before mapping names and return { tags, hasMore }. Also import parseDuration
from "parse-duration" at the top if not already imported.


#buildRunIdsQuery(
filterOptions: FilterRunsOptions,
page: { size: number; cursor?: string; direction?: "forward" | "backward" }
Expand Down
Loading