diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 393acb761..ccc928b0f 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -91,6 +91,24 @@ export const TaskRunListSearchFilters = z.object({ export type TaskRunListSearchFilters = z.infer; +export function getRunFiltersFromSearchParams(searchParams: URLSearchParams) { + return { + cursor: searchParams.get("cursor") ?? undefined, + direction: searchParams.get("direction") ?? undefined, + statuses: searchParams.getAll("statuses"), + tasks: searchParams.getAll("tasks"), + period: searchParams.get("period") ?? undefined, + bulkId: searchParams.get("bulkId") ?? undefined, + tags: searchParams.getAll("tags").map((t) => decodeURIComponent(t)), + from: searchParams.get("from") ?? undefined, + to: searchParams.get("to") ?? undefined, + rootOnly: searchParams.has("rootOnly") ? searchParams.get("rootOnly") === "true" : undefined, + runId: searchParams.get("runId") ?? undefined, + batchId: searchParams.get("batchId") ?? undefined, + scheduleId: searchParams.get("scheduleId") ?? undefined, + }; +} + type RunFiltersProps = { possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[]; bulkActions: { diff --git a/apps/webapp/app/presenters/RunFilters.server.ts b/apps/webapp/app/presenters/RunFilters.server.ts index 46edfa78e..91cf02e94 100644 --- a/apps/webapp/app/presenters/RunFilters.server.ts +++ b/apps/webapp/app/presenters/RunFilters.server.ts @@ -1,4 +1,7 @@ -import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { + getRunFiltersFromSearchParams, + TaskRunListSearchFilters, +} from "~/components/runs/v3/RunFilters"; import { getRootOnlyFilterPreference } from "~/services/preferences/uiPreferences.server"; export async function getRunFiltersFromRequest(request: Request) { @@ -10,21 +13,7 @@ export async function getRunFiltersFromRequest(request: Request) { rootOnlyValue = await getRootOnlyFilterPreference(request); } - const s = { - cursor: url.searchParams.get("cursor") ?? undefined, - direction: url.searchParams.get("direction") ?? undefined, - statuses: url.searchParams.getAll("statuses"), - tasks: url.searchParams.getAll("tasks"), - period: url.searchParams.get("period") ?? undefined, - bulkId: url.searchParams.get("bulkId") ?? undefined, - tags: url.searchParams.getAll("tags").map((t) => decodeURIComponent(t)), - from: url.searchParams.get("from") ?? undefined, - to: url.searchParams.get("to") ?? undefined, - rootOnly: rootOnlyValue, - runId: url.searchParams.get("runId") ?? undefined, - batchId: url.searchParams.get("batchId") ?? undefined, - scheduleId: url.searchParams.get("scheduleId") ?? undefined, - }; + const s = getRunFiltersFromSearchParams(url.searchParams); const { tasks, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx index e73b1c883..4795d07ea 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx @@ -57,6 +57,15 @@ import { v3TestPath, } from "~/utils/pathBuilder"; import { ListPagination } from "../../components/ListPagination"; +import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { CreateBulkActionInspector } from "../resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction"; export const meta: MetaFunction = () => { return [ @@ -70,15 +79,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); - const url = new URL(request.url); - - let rootOnlyValue = false; - if (url.searchParams.has("rootOnly")) { - rootOnlyValue = url.searchParams.get("rootOnly") === "true"; - } else { - rootOnlyValue = await getRootOnlyFilterPreference(request); - } - const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Error("Project not found"); @@ -89,71 +89,27 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Error("Environment not found"); } - const s = { - cursor: url.searchParams.get("cursor") ?? undefined, - direction: url.searchParams.get("direction") ?? undefined, - statuses: url.searchParams.getAll("statuses"), - environments: [environment.id], - tasks: url.searchParams.getAll("tasks"), - period: url.searchParams.get("period") ?? undefined, - bulkId: url.searchParams.get("bulkId") ?? undefined, - tags: url.searchParams.getAll("tags").map((t) => decodeURIComponent(t)), - from: url.searchParams.get("from") ?? undefined, - to: url.searchParams.get("to") ?? undefined, - rootOnly: rootOnlyValue, - runId: url.searchParams.get("runId") ?? undefined, - batchId: url.searchParams.get("batchId") ?? undefined, - scheduleId: url.searchParams.get("scheduleId") ?? undefined, - }; - const { - tasks, - versions, - statuses, - environments, - tags, - period, - bulkId, - from, - to, - cursor, - direction, - rootOnly, - runId, - batchId, - scheduleId, - } = TaskRunListSearchFilters.parse(s); - if (!clickhouseClient) { throw new Error("Clickhouse is not supported yet"); } + const filters = await getRunFiltersFromRequest(request); + const presenter = new NextRunListPresenter($replica, clickhouseClient); const list = presenter.call(project.organizationId, environment.id, { userId, projectId: project.id, - tasks, - versions, - statuses, - tags, - period, - bulkId, - from, - to, - batchId, - runIds: runId ? [runId] : undefined, - scheduleId, - rootOnly, - direction: direction, - cursor: cursor, + ...filters, }); - const session = await setRootOnlyFilterPreference(rootOnlyValue, request); + const session = await setRootOnlyFilterPreference(filters.rootOnly, request); const cookieValue = await uiPreferencesStorage.commitSession(session); return typeddefer( { data: list, - rootOnlyDefault: rootOnlyValue, + rootOnlyDefault: filters.rootOnly, + filters, }, { headers: { @@ -164,12 +120,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { data, rootOnlyDefault } = useTypedLoaderData(); + const { data, rootOnlyDefault, filters } = useTypedLoaderData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; const { isConnected } = useDevPresence(); + const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); + const searchParams = useSearchParams(); + + const isShowingBulkActionInspector = searchParams.has("bulkInspector"); return ( <> @@ -194,65 +154,77 @@ export default function Page() { maxSelectedItemCount={BULK_ACTION_RUN_LIMIT} > {({ selectedItems }) => ( -
- -
- - Loading runs -
-
- } - > - - {(list) => ( - <> - {list.runs.length === 0 && !list.hasAnyRuns ? ( - list.possibleTasks.length === 0 ? ( - - ) : ( - - ) - ) : ( -
-
- -
- -
-
- - -
- )} - + + +
- - -
+ > + +
+ + Loading runs +
+ + } + > + + {(list) => ( + <> + {list.runs.length === 0 && !list.hasAnyRuns ? ( + list.possibleTasks.length === 0 ? ( + + ) : ( + + ) + ) : ( +
+
+ +
+ +
+
+ + +
+ )} + + )} +
+
+ + +
+ {isShowingBulkActionInspector && ( + <> + + + + + + )} +
)} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction.tsx new file mode 100644 index 000000000..5ce8a2e6f --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction.tsx @@ -0,0 +1,170 @@ +import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { XCircleIcon } from "@heroicons/react/24/outline"; +import { Form, useActionData, useFetcher } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; +import { useEffect } from "react"; +import { typedjson, useTypedFetcher } from "remix-typedjson"; +import { z } from "zod"; +import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Header2 } from "~/components/primitives/Headers"; +import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { $replica, type PrismaClient } from "~/db.server"; +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 { redirectWithSuccessMessage } from "~/models/message.server"; +import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { RunsRepository } from "~/services/runsRepository.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { v3RunsPath } from "~/utils/pathBuilder"; + +const Params = z.object({ + organizationId: z.string(), + projectId: z.string(), + environmentId: z.string(), +}); + +const searchParams = z.object({ + mode: z.union([z.literal("selected"), z.literal("filter")]).default("filter"), + action: z.union([z.literal("cancel"), z.literal("replay")]).default("cancel"), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + + const { organizationId, projectId, environmentId } = Params.parse(params); + const filters = await getRunFiltersFromRequest(request); + const { mode, action } = searchParams.parse( + Object.fromEntries(new URL(request.url).searchParams) + ); + + //todo do a ClickHouse Query with the filters + if (!clickhouseClient) { + throw new Error("Clickhouse client not found"); + } + + const runsRepository = new RunsRepository({ + clickhouse: clickhouseClient, + prisma: $replica as PrismaClient, + }); + + const count = await runsRepository.countRuns({ + organizationId, + projectId, + environmentId, + // ...filters, + }); + + return typedjson({ + filters, + mode, + action, + count, + }); +} + +export async function action({ params, request }: ActionFunctionArgs) { + const { organizationId, projectId, environmentId } = Params.parse(params); + const filters = await getRunFiltersFromRequest(request); + + return redirectWithSuccessMessage("/", request, "SORTED"); +} + +export function CreateBulkActionInspector({ filters }: { filters: TaskRunListSearchFilters }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const fetcher = useTypedFetcher(); + const lastSubmission = useActionData(); + const { value } = useSearchParams(); + const location = useOptimisticLocation(); + + useEffect(() => { + fetcher.load( + `/resources/orgs/${organization.id}/projects/${project.id}/environments/${environment.id}/runs/bulkaction${location.search}` + ); + }, [organization.id, project.id, environment.id, location.search]); + + const mode = value("mode"); + const action = value("action"); + + const data = fetcher.data != null ? fetcher.data : undefined; + + return ( +
+
+ Create a bulk action + +
+
+
+ {data?.count} + +
+
+
+ +
+
+ ); + + // return ( + //
+ // + // + // + // This will permanently make this branch{" "} + // read-only. You won't be able to trigger runs, + // execute runs, or use the API for this branch. + // + // + // You will still be able to view the branch and its associated runs. + // + // Once archived you can create a new branch with the same name. + // {form.error} + // + // Archive branch + // + // } + // cancelButton={ + // + // + // + // } + // /> + // + // ); +} diff --git a/apps/webapp/app/services/runsRepository.server.ts b/apps/webapp/app/services/runsRepository.server.ts index 6d2befbb2..b8a792ff4 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository.server.ts @@ -1,4 +1,5 @@ import { type ClickHouse } from "@internal/clickhouse"; +import { ClickhouseQueryBuilder } from "@internal/clickhouse/dist/src/client/queryBuilder"; import { type Tracer } from "@internal/tracing"; import { type Logger, type LogLevel } from "@trigger.dev/core/logger"; import { type TaskRunStatus } from "@trigger.dev/database"; @@ -12,7 +13,7 @@ export type RunsRepositoryOptions = { tracer?: Tracer; }; -export type ListRunsOptions = { +export type FilterRunsOptions = { organizationId: string; projectId: string; environmentId: string; @@ -30,7 +31,9 @@ export type ListRunsOptions = { batchId?: string; runFriendlyIds?: string[]; runIds?: string[]; - //pagination +}; + +export type ListRunsOptions = FilterRunsOptions & { page: { size: number; cursor?: string; @@ -43,77 +46,7 @@ export class RunsRepository { async listRuns(options: ListRunsOptions) { const queryBuilder = this.options.clickhouse.taskRuns.queryBuilder(); - queryBuilder - .where("organization_id = {organizationId: String}", { - organizationId: options.organizationId, - }) - .where("project_id = {projectId: String}", { - projectId: options.projectId, - }) - .where("environment_id = {environmentId: String}", { - environmentId: options.environmentId, - }); - - if (options.tasks && options.tasks.length > 0) { - queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks }); - } - - if (options.versions && options.versions.length > 0) { - queryBuilder.where("task_version IN {versions: Array(String)}", { - versions: options.versions, - }); - } - - if (options.statuses && options.statuses.length > 0) { - queryBuilder.where("status IN {statuses: Array(String)}", { statuses: options.statuses }); - } - - if (options.tags && options.tags.length > 0) { - queryBuilder.where("hasAny(tags, {tags: Array(String)})", { tags: options.tags }); - } - - if (options.scheduleId) { - queryBuilder.where("schedule_id = {scheduleId: String}", { scheduleId: options.scheduleId }); - } - - // Period is a number of milliseconds duration - if (options.period) { - queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", { - period: new Date(Date.now() - options.period).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 (typeof options.isTest === "boolean") { - queryBuilder.where("is_test = {isTest: Boolean}", { isTest: options.isTest }); - } - - if (options.rootOnly) { - queryBuilder.where("root_run_id = ''"); - } - - if (options.batchId) { - queryBuilder.where("batch_id = {batchId: String}", { batchId: options.batchId }); - } - - if (options.runFriendlyIds && options.runFriendlyIds.length > 0) { - queryBuilder.where("friendly_id IN {runFriendlyIds: Array(String)}", { - runFriendlyIds: options.runFriendlyIds, - }); - } - - if (options.runIds && options.runIds.length > 0) { - queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds: options.runIds }); - } + applyRunFiltersToQueryBuilder(queryBuilder, options); if (options.page.cursor) { if (options.page.direction === "forward") { @@ -222,4 +155,98 @@ export class RunsRepository { }, }; } + + async countRuns(options: FilterRunsOptions) { + const queryBuilder = this.options.clickhouse.taskRuns.countQueryBuilder(); + applyRunFiltersToQueryBuilder(queryBuilder, options); + + const [queryError, result] = await queryBuilder.execute(); + + if (queryError) { + throw queryError; + } + + if (result.length === 0) { + throw new Error("No count rows returned"); + } + + return result[0].count; + } +} + +function applyRunFiltersToQueryBuilder( + queryBuilder: ClickhouseQueryBuilder, + options: FilterRunsOptions +) { + queryBuilder + .where("organization_id = {organizationId: String}", { + organizationId: options.organizationId, + }) + .where("project_id = {projectId: String}", { + projectId: options.projectId, + }) + .where("environment_id = {environmentId: String}", { + environmentId: options.environmentId, + }); + + if (options.tasks && options.tasks.length > 0) { + queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks }); + } + + if (options.versions && options.versions.length > 0) { + queryBuilder.where("task_version IN {versions: Array(String)}", { + versions: options.versions, + }); + } + + if (options.statuses && options.statuses.length > 0) { + queryBuilder.where("status IN {statuses: Array(String)}", { statuses: options.statuses }); + } + + if (options.tags && options.tags.length > 0) { + queryBuilder.where("hasAny(tags, {tags: Array(String)})", { tags: options.tags }); + } + + if (options.scheduleId) { + queryBuilder.where("schedule_id = {scheduleId: String}", { scheduleId: options.scheduleId }); + } + + // Period is a number of milliseconds duration + if (options.period) { + queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", { + period: new Date(Date.now() - options.period).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 (typeof options.isTest === "boolean") { + queryBuilder.where("is_test = {isTest: Boolean}", { isTest: options.isTest }); + } + + if (options.rootOnly) { + queryBuilder.where("root_run_id = ''"); + } + + if (options.batchId) { + queryBuilder.where("batch_id = {batchId: String}", { batchId: options.batchId }); + } + + if (options.runFriendlyIds && options.runFriendlyIds.length > 0) { + queryBuilder.where("friendly_id IN {runFriendlyIds: Array(String)}", { + runFriendlyIds: options.runFriendlyIds, + }); + } + + if (options.runIds && options.runIds.length > 0) { + queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds: options.runIds }); + } } diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index ee2967ea9..c8254bc23 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -10,6 +10,7 @@ import { getCurrentRunningStats, getAverageDurations, getTaskUsageByOrganization, + getTaskRunsCountQueryBuilder, } from "./taskRuns.js"; import { Logger, type LogLevel } from "@trigger.dev/core/logger"; import type { Agent as HttpAgent } from "http"; @@ -144,6 +145,7 @@ export class ClickHouse { insert: insertTaskRuns(this.writer), insertPayloads: insertRawTaskRunPayloads(this.writer), queryBuilder: getTaskRunsQueryBuilder(this.reader), + countQueryBuilder: getTaskRunsCountQueryBuilder(this.reader), getTaskActivity: getTaskActivityQueryBuilder(this.reader), getCurrentRunningStats: getCurrentRunningStats(this.reader), getAverageDurations: getAverageDurations(this.reader), diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts index b57ddbfc8..a32c799ac 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -103,6 +103,17 @@ export function getTaskRunsQueryBuilder(ch: ClickhouseReader, settings?: ClickHo }); } +export function getTaskRunsCountQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) { + return ch.queryBuilder({ + name: "getTaskRunsCount", + baseQuery: "SELECT count() as count FROM trigger_dev.task_runs_v2 FINAL", + schema: z.object({ + count: z.number().int(), + }), + settings, + }); +} + export const TaskActivityQueryResult = z.object({ task_identifier: z.string(), status: z.string(),