From d1c2007d90325c87531536afd908a5ed3ca2f66b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 25 Jun 2025 16:38:59 +0100 Subject: [PATCH 001/212] useSearchParams has --- apps/webapp/app/hooks/useSearchParam.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/webapp/app/hooks/useSearchParam.ts b/apps/webapp/app/hooks/useSearchParam.ts index c0f939abcc..d7340db095 100644 --- a/apps/webapp/app/hooks/useSearchParam.ts +++ b/apps/webapp/app/hooks/useSearchParam.ts @@ -72,11 +72,19 @@ export function useSearchParams() { [location, search] ); + const has = useCallback( + (param: string) => { + return search.has(param); + }, + [location, search] + ); + return { value, values, set, replace, del, + has, }; } From c8d490c2bf20efc33e65d0a86d81a7b37e08a08c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 25 Jun 2025 16:38:59 +0100 Subject: [PATCH 002/212] useSearchParams has From 4278b7522816c303107e969478cc018d237a85c4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 25 Jun 2025 16:38:59 +0100 Subject: [PATCH 003/212] useSearchParams has From d437b34d8b7c10cde83fe2cdd73550583ea97955 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 25 Jun 2025 16:39:07 +0100 Subject: [PATCH 004/212] Consistent way to get the run filters --- .../app/presenters/RunFilters.server.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/webapp/app/presenters/RunFilters.server.ts diff --git a/apps/webapp/app/presenters/RunFilters.server.ts b/apps/webapp/app/presenters/RunFilters.server.ts new file mode 100644 index 0000000000..46edfa78ee --- /dev/null +++ b/apps/webapp/app/presenters/RunFilters.server.ts @@ -0,0 +1,61 @@ +import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { getRootOnlyFilterPreference } from "~/services/preferences/uiPreferences.server"; + +export async function getRunFiltersFromRequest(request: Request) { + 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 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 { + tasks, + versions, + statuses, + tags, + period, + bulkId, + from, + to, + cursor, + direction, + runId, + batchId, + scheduleId, + } = TaskRunListSearchFilters.parse(s); + + return { + tasks, + versions, + statuses, + tags, + period, + bulkId, + from, + to, + batchId, + runIds: runId ? [runId] : undefined, + scheduleId, + rootOnly: rootOnlyValue, + direction: direction, + cursor: cursor, + }; +} From 57d9560561f5e5632bc3960d03eb3c3ee546e605 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 25 Jun 2025 16:39:07 +0100 Subject: [PATCH 005/212] Consistent way to get the run filters From a920cf4499e03bebac1d6d9868e3a4c9b669a091 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 25 Jun 2025 16:39:07 +0100 Subject: [PATCH 006/212] Consistent way to get the run filters From dbcbd5c05ac7abde0a141f3a2a1908de61d0feae Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 12:44:31 +0100 Subject: [PATCH 007/212] Initial work on the new bulk actions --- .../app/components/runs/v3/RunFilters.tsx | 18 ++ .../app/presenters/RunFilters.server.ts | 21 +- .../route.tsx | 206 ++++++++---------- ...onments.$environmentId.runs.bulkaction.tsx | 170 +++++++++++++++ .../app/services/runsRepository.server.ts | 177 ++++++++------- internal-packages/clickhouse/src/index.ts | 2 + internal-packages/clickhouse/src/taskRuns.ts | 11 + 7 files changed, 395 insertions(+), 210 deletions(-) create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction.tsx diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 393acb7616..ccc928b0f1 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 46edfa78ee..91cf02e943 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 e73b1c883e..4795d07ea8 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 0000000000..5ce8a2e6fd --- /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 154e6967c3..b8a792ff43 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,81 +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 }); - } else { - queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { - to: Date.now(), - }); - } - - 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") { @@ -226,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 7e0894ff1a..44ec2ec60b 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 86830b5bd7..e30affaf84 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -107,6 +107,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(), From 03d411a9495e4235c5c72899cad6b4f9692a0423 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 12:44:31 +0100 Subject: [PATCH 008/212] Initial work on the new bulk actions From 746af2938ea3a5e569f4f5aab315aab1e867f9b0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 12:44:31 +0100 Subject: [PATCH 009/212] Initial work on the new bulk actions From 30ca8406841f399e653a1f12f3801b6cced61b08 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:00:46 +0100 Subject: [PATCH 010/212] WIP actions and filtering --- .../app/components/runs/v3/RunFilters.tsx | 86 ++++--- .../app/components/runs/v3/TaskRunStatus.tsx | 1 + .../v3/NextRunListPresenter.server.ts | 68 +----- .../route.tsx | 16 +- .../app/routes/account.tokens/route.tsx | 1 - ...onments.$environmentId.runs.bulkaction.tsx | 210 ++++++++++++------ .../app/services/runsRepository.server.ts | 114 +++++++++- apps/webapp/app/utils/pathBuilder.ts | 14 +- apps/webapp/test/runsRepository.test.ts | 2 +- 9 files changed, 326 insertions(+), 186 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index ccc928b0f1..e98f53ff41 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -54,31 +54,46 @@ import { } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; -export const TaskAttemptStatus = z.enum(allTaskRunStatuses); +export const RunStatus = z.enum(allTaskRunStatuses); + +const StringOrStringArray = z.preprocess((value) => { + if (typeof value === "string") { + if (value.length > 0) { + return [value]; + } + + return undefined; + } + + if (Array.isArray(value)) { + return value.filter((v) => typeof v === "string" && v.length > 0); + } + + return undefined; +}, z.string().array().optional()); export const TaskRunListSearchFilters = z.object({ cursor: z.string().optional(), direction: z.enum(["forward", "backward"]).optional(), - environments: z.preprocess( - (value) => (typeof value === "string" ? [value] : value), - z.string().array().optional() - ), - tasks: z.preprocess( - (value) => (typeof value === "string" ? [value] : value), - z.string().array().optional() - ), - versions: z.preprocess( - (value) => (typeof value === "string" ? [value] : value), - z.string().array().optional() - ), - statuses: z.preprocess( - (value) => (typeof value === "string" ? [value] : value), - TaskAttemptStatus.array().optional() - ), - tags: z.preprocess( - (value) => (typeof value === "string" ? [value] : value), - z.string().array().optional() - ), + environments: StringOrStringArray, + tasks: StringOrStringArray, + versions: StringOrStringArray, + statuses: z.preprocess((value) => { + if (typeof value === "string") { + if (value.length > 0) { + return [value]; + } + + return undefined; + } + + if (Array.isArray(value)) { + return value.filter((v) => typeof v === "string" && v.length > 0); + } + + return undefined; + }, RunStatus.array().optional()), + tags: StringOrStringArray, bulkId: z.string().optional(), period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), from: z.coerce.number().optional(), @@ -91,8 +106,10 @@ export const TaskRunListSearchFilters = z.object({ export type TaskRunListSearchFilters = z.infer; -export function getRunFiltersFromSearchParams(searchParams: URLSearchParams) { - return { +export function getRunFiltersFromSearchParams( + searchParams: URLSearchParams +): TaskRunListSearchFilters { + const params = { cursor: searchParams.get("cursor") ?? undefined, direction: searchParams.get("direction") ?? undefined, statuses: searchParams.getAll("statuses"), @@ -107,6 +124,14 @@ export function getRunFiltersFromSearchParams(searchParams: URLSearchParams) { batchId: searchParams.get("batchId") ?? undefined, scheduleId: searchParams.get("scheduleId") ?? undefined, }; + + const parsed = TaskRunListSearchFilters.safeParse(params); + + if (!parsed.success) { + return {}; + } + + return parsed.data; } type RunFiltersProps = { @@ -352,7 +377,7 @@ function AppliedStatusFilter() { const { values, del } = useSearchParams(); const statuses = values("statuses"); - if (statuses.length === 0) { + if (statuses.length === 0 || statuses.every((v) => v === "")) { return null; } @@ -439,7 +464,7 @@ function TasksDropdown({ function AppliedTaskFilter({ possibleTasks }: Pick) { const { values, del } = useSearchParams(); - if (values("tasks").length === 0) { + if (values("tasks").length === 0 || values("tasks").every((v) => v === "")) { return null; } @@ -580,12 +605,15 @@ function TagsDropdown({ const handleChange = (values: string[]) => { clearSearchValue(); replace({ - tags: values, + tags: values.length > 0 ? values : undefined, cursor: undefined, direction: undefined, }); }; + const tagValues = values("tags").filter((v) => v !== ""); + const selected = tagValues.length > 0 ? tagValues : undefined; + const fetcher = useFetcher(); useEffect(() => { @@ -599,7 +627,7 @@ function TagsDropdown({ const filtered = useMemo(() => { let items: string[] = []; if (searchValue === "") { - items = values("tags"); + items = selected ?? []; } if (fetcher.data === undefined) { @@ -612,7 +640,7 @@ function TagsDropdown({ }, [searchValue, fetcher.data]); return ( - + {trigger} v === "")) { return null; } diff --git a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx index fd2143ecb8..619b407e07 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx @@ -265,6 +265,7 @@ export function runStatusTitle(status: TaskRunStatus): string { case "TIMED_OUT": return "Timed out"; default: { + console.error("status", status); assertNever(status); } } diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index 9c3d65edb6..c7ee6639bc 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -122,69 +122,6 @@ export class NextRunListPresenter { throw new ServiceValidationError("No environment found"); } - //we can restrict to specific runs using bulkId, or batchId - let restrictToRunIds: undefined | string[] = undefined; - - //bulk id - if (bulkId) { - const bulkAction = await this.replica.bulkActionGroup.findFirst({ - select: { - items: { - select: { - destinationRunId: true, - }, - }, - }, - where: { - friendlyId: bulkId, - }, - }); - - if (bulkAction) { - const runIds = bulkAction.items.map((item) => item.destinationRunId).filter(Boolean); - restrictToRunIds = runIds; - } - } - - //batch id is a friendly id - if (batchId) { - const batch = await this.replica.batchTaskRun.findFirst({ - select: { - id: true, - }, - where: { - friendlyId: batchId, - runtimeEnvironmentId: environmentId, - }, - }); - - if (batch) { - batchId = batch.id; - } - } - - //scheduleId can be a friendlyId - if (scheduleId && scheduleId.startsWith("sched_")) { - const schedule = await this.replica.taskSchedule.findFirst({ - select: { - id: true, - }, - where: { - friendlyId: scheduleId, - projectId: projectId, - }, - }); - - if (schedule) { - scheduleId = schedule?.id; - } - } - - //show all runs if we are filtering by batchId or runId - if (batchId || runIds?.length || scheduleId || tasks?.length) { - rootOnly = false; - } - const runsRepository = new RunsRepository({ clickhouse: this.clickhouse, prisma: this.replica as PrismaClient, @@ -204,14 +141,13 @@ export class NextRunListPresenter { statuses, tags, scheduleId, - period: periodMs ?? undefined, + period, from: time.from ? time.from.getTime() : undefined, to: time.to ? clampToNow(time.to).getTime() : undefined, isTest, rootOnly, batchId, - runFriendlyIds: runIds, - runIds: restrictToRunIds, + runIds, page: { size: pageSize, cursor, 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 4795d07ea8..5f1fa3e7a7 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 @@ -31,7 +31,7 @@ import { import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; -import { RunsFilters, TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { RunsFilters, getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; import { $replica } from "~/db.server"; @@ -52,6 +52,7 @@ import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema, + v3CreateBulkActionPath, v3ProjectPath, v3RunsNextPath, v3TestPath, @@ -195,6 +196,17 @@ export default function Page() { rootOnlyDefault={rootOnlyDefault} />
+ + Bulk action +
@@ -220,7 +232,7 @@ export default function Page() { <> - + )} diff --git a/apps/webapp/app/routes/account.tokens/route.tsx b/apps/webapp/app/routes/account.tokens/route.tsx index f95f53f453..7b7945c70f 100644 --- a/apps/webapp/app/routes/account.tokens/route.tsx +++ b/apps/webapp/app/routes/account.tokens/route.tsx @@ -257,7 +257,6 @@ function CreatePersonalAccessToken() { defaultValue="" icon={ShieldCheckIcon} autoComplete="off" - data-1p-ignore /> This will help you to identify your token. Tokens called "cli" are automatically 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 index 5ce8a2e6fd..930515c6b8 100644 --- 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 @@ -7,6 +7,8 @@ import { typedjson, useTypedFetcher } from "remix-typedjson"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { InputGroup } from "~/components/primitives/InputGroup"; import { Header2 } from "~/components/primitives/Headers"; import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { $replica, type PrismaClient } from "~/db.server"; @@ -21,7 +23,14 @@ 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"; +import { v3RunsNextPath, v3RunsPath } from "~/utils/pathBuilder"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Hint } from "~/components/primitives/Hint"; +import { RadioGroupItem, RadioGroup } from "~/components/primitives/RadioButton"; +import { formatNumber } from "~/utils/numberFormatter"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { formatDateTime } from "~/components/primitives/DateTime"; const Params = z.object({ organizationId: z.string(), @@ -57,7 +66,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { organizationId, projectId, environmentId, - // ...filters, + ...filters, }); return typedjson({ @@ -75,12 +84,17 @@ export async function action({ params, request }: ActionFunctionArgs) { return redirectWithSuccessMessage("/", request, "SORTED"); } -export function CreateBulkActionInspector({ filters }: { filters: TaskRunListSearchFilters }) { +export function CreateBulkActionInspector({ + filters, + selectedItems, +}: { + filters: TaskRunListSearchFilters; + selectedItems: Set; +}) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const fetcher = useTypedFetcher(); - const lastSubmission = useActionData(); const { value } = useSearchParams(); const location = useOptimisticLocation(); @@ -90,81 +104,129 @@ export function CreateBulkActionInspector({ filters }: { filters: TaskRunListSea ); }, [organization.id, project.id, environment.id, location.search]); - const mode = value("mode"); - const action = value("action"); + const mode = value("mode") ?? "filter"; + const action = value("action") ?? "replay"; const data = fetcher.data != null ? fetcher.data : undefined; + const formattedFilteredRunsCount = + data?.count !== undefined ? ( + `~${formatNumber(data.count)}` + ) : ( + + ); + + const closedSearchParams = new URLSearchParams(location.search); + closedSearchParams.delete("bulkInspector"); + 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 b8a792ff43..08f3d01731 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository.server.ts @@ -1,8 +1,10 @@ import { type ClickHouse } from "@internal/clickhouse"; -import { ClickhouseQueryBuilder } from "@internal/clickhouse/dist/src/client/queryBuilder"; +import { type 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"; +import parseDuration from "parse-duration"; +import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { type PrismaClient } from "~/db.server"; export type RunsRepositoryOptions = { @@ -13,7 +15,27 @@ export type RunsRepositoryOptions = { tracer?: Tracer; }; -export type FilterRunsOptions = { +type RunListInputOptions = { + organizationId: string; + projectId: string; + environmentId: string; + //filters + tasks?: string[]; + versions?: string[]; + statuses?: TaskRunStatus[]; + tags?: string[]; + scheduleId?: string; + period?: string; + bulkId?: string; + from?: number; + to?: number; + isTest?: boolean; + rootOnly?: boolean; + batchId?: string; + runIds?: string[]; +}; + +type FilterRunsOptions = { organizationId: string; projectId: string; environmentId: string; @@ -30,10 +52,9 @@ export type FilterRunsOptions = { rootOnly?: boolean; batchId?: string; runFriendlyIds?: string[]; - runIds?: string[]; }; -export type ListRunsOptions = FilterRunsOptions & { +type Pagination = { page: { size: number; cursor?: string; @@ -41,12 +62,17 @@ export type ListRunsOptions = FilterRunsOptions & { }; }; +export type ListRunsOptions = RunListInputOptions & Pagination; + export class RunsRepository { constructor(private readonly options: RunsRepositoryOptions) {} - async listRuns(options: ListRunsOptions) { + async listRunIds(options: ListRunsOptions) { const queryBuilder = this.options.clickhouse.taskRuns.queryBuilder(); - applyRunFiltersToQueryBuilder(queryBuilder, options); + applyRunFiltersToQueryBuilder( + queryBuilder, + await this.#convertRunListInputOptionsToFilterRunsOptions(options) + ); if (options.page.cursor) { if (options.page.direction === "forward") { @@ -72,6 +98,11 @@ export class RunsRepository { } const runIds = result.map((row) => row.run_id); + return runIds; + } + + async listRuns(options: ListRunsOptions) { + const runIds = await this.listRunIds(options); // If there are more runs than the page size, we need to fetch the next page const hasMore = runIds.length > options.page.size; @@ -156,9 +187,12 @@ export class RunsRepository { }; } - async countRuns(options: FilterRunsOptions) { + async countRuns(options: RunListInputOptions) { const queryBuilder = this.options.clickhouse.taskRuns.countQueryBuilder(); - applyRunFiltersToQueryBuilder(queryBuilder, options); + applyRunFiltersToQueryBuilder( + queryBuilder, + await this.#convertRunListInputOptionsToFilterRunsOptions(options) + ); const [queryError, result] = await queryBuilder.execute(); @@ -172,6 +206,64 @@ export class RunsRepository { return result[0].count; } + + async #convertRunListInputOptionsToFilterRunsOptions( + options: RunListInputOptions + ): Promise { + const convertedOptions: FilterRunsOptions = { + ...options, + period: undefined, + }; + + // Convert time period to ms + const time = timeFilters({ + period: options.period, + from: options.from, + to: options.to, + }); + convertedOptions.period = time.period ? parseDuration(time.period) ?? undefined : undefined; + + // batch friendlyId to id + if (options.batchId && options.batchId.startsWith("batch_")) { + const batch = await this.options.prisma.batchTaskRun.findFirst({ + select: { + id: true, + }, + where: { + friendlyId: options.batchId, + runtimeEnvironmentId: options.environmentId, + }, + }); + + if (batch) { + convertedOptions.batchId = batch.id; + } + } + + // scheduleId can be a friendlyId + if (options.scheduleId && options.scheduleId.startsWith("sched_")) { + const schedule = await this.options.prisma.taskSchedule.findFirst({ + select: { + id: true, + }, + where: { + friendlyId: options.scheduleId, + projectId: options.projectId, + }, + }); + + if (schedule) { + convertedOptions.scheduleId = schedule?.id; + } + } + + // Show all runs if we are filtering by batchId or runId + if (options.batchId || options.runIds?.length || options.scheduleId || options.tasks?.length) { + convertedOptions.rootOnly = false; + } + + return convertedOptions; + } } function applyRunFiltersToQueryBuilder( @@ -240,13 +332,11 @@ function applyRunFiltersToQueryBuilder( queryBuilder.where("batch_id = {batchId: String}", { batchId: options.batchId }); } + // TODO new bulk action filtering + 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/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index c4a4247ac1..5f843344bd 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -245,7 +245,19 @@ export function v3RunsNextPath( ) { const searchParams = objectToSearchParams(filters); const query = searchParams ? `?${searchParams.toString()}` : ""; - return `${v3EnvironmentPath(organization, project, environment)}/runs/next${query}`; + return `${v3EnvironmentPath(organization, project, environment)}/next/runs${query}`; +} + +export function v3CreateBulkActionPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + filters?: TaskRunListSearchFilters +) { + const searchParams = objectToSearchParams(filters) ?? new URLSearchParams(); + searchParams.set("bulkInspector", "show"); + const query = `?${searchParams.toString()}`; + return `${v3RunsNextPath(organization, project, environment)}${query}`; } export function v3RunPath( diff --git a/apps/webapp/test/runsRepository.test.ts b/apps/webapp/test/runsRepository.test.ts index 7ee3b05b4f..55236e4147 100644 --- a/apps/webapp/test/runsRepository.test.ts +++ b/apps/webapp/test/runsRepository.test.ts @@ -1063,7 +1063,7 @@ describe("RunsRepository", () => { projectId: project.id, environmentId: runtimeEnvironment.id, organizationId: organization.id, - runFriendlyIds: ["run_abc", "run_xyz"], + runIds: ["run_abc", "run_xyz"], }); expect(runs).toHaveLength(2); From 7b2fb103370738d5d3ea5e8d03fa0c742ed56b01 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:00:46 +0100 Subject: [PATCH 011/212] WIP actions and filtering From abd31dcba3b9e756b52ed77349ce49ec23d7d10f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:00:46 +0100 Subject: [PATCH 012/212] WIP actions and filtering From 588c5385f9be4442111830e2cbdd248ed4f841a7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jun 2025 10:34:54 +0100 Subject: [PATCH 013/212] Empty filter arrays are set to undefined --- apps/webapp/app/components/runs/v3/RunFilters.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index e98f53ff41..de8d9904e9 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -112,11 +112,20 @@ export function getRunFiltersFromSearchParams( const params = { cursor: searchParams.get("cursor") ?? undefined, direction: searchParams.get("direction") ?? undefined, - statuses: searchParams.getAll("statuses"), - tasks: searchParams.getAll("tasks"), + statuses: + searchParams.getAll("statuses").filter((v) => v.length > 0).length > 0 + ? searchParams.getAll("statuses") + : undefined, + tasks: + searchParams.getAll("tasks").filter((v) => v.length > 0).length > 0 + ? searchParams.getAll("tasks") + : undefined, period: searchParams.get("period") ?? undefined, bulkId: searchParams.get("bulkId") ?? undefined, - tags: searchParams.getAll("tags").map((t) => decodeURIComponent(t)), + tags: + searchParams.getAll("tags").filter((v) => v.length > 0).length > 0 + ? searchParams.getAll("tags").map((t) => decodeURIComponent(t)) + : undefined, from: searchParams.get("from") ?? undefined, to: searchParams.get("to") ?? undefined, rootOnly: searchParams.has("rootOnly") ? searchParams.get("rootOnly") === "true" : undefined, From f9f18c062b3f4f09adf888967e10e3ffe6e04a55 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jun 2025 10:34:54 +0100 Subject: [PATCH 014/212] Empty filter arrays are set to undefined From 1dd6c9795a2cc158bdef064f983cc787307bc239 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 27 Jun 2025 10:34:54 +0100 Subject: [PATCH 015/212] Empty filter arrays are set to undefined From 8516ea0ef3d2e26307bdbe9babeadb799f4d0381 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 12 Jun 2025 17:38:44 +0100 Subject: [PATCH 016/212] WIP prisma schema Removed extra runtimeEnvironmentId --- .../database/prisma/schema.prisma | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 2ce5de2b5c..004bdff43f 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -57,6 +57,7 @@ model User { personalAccessTokens PersonalAccessToken[] deployments WorkerDeployment[] backupCodes MfaBackupCode[] + bulkActions BulkActionGroup[] } model MfaBackupCode { @@ -292,6 +293,7 @@ model RuntimeEnvironment { workerInstances WorkerInstance[] executionSnapshots TaskRunExecutionSnapshot[] waitpointTags WaitpointTag[] + BulkActionGroup BulkActionGroup[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -1977,12 +1979,34 @@ model BulkActionGroup { project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String + /// If this is set then it's a V2 Bulk Action that supports queries + environment RuntimeEnvironment? @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String? + type BulkActionType items BulkActionItem[] /// When the group is created it's pending. After we've processed all the items it's completed. This does not mean the associated runs are completed. status BulkActionStatus @default(PENDING) + /// The query that will be executed to get the runs to process (if null, we are passing run ids directly) + queryName String? + /// The params that will be passed to the query + params Json? + /// The cursor that will be passed to the query (null for the first page) + cursor Json? + /// The number of runs that have been processed + processedCount Int @default(0) + /// The total number of runs that will be processed + totalCount Int @default(0) + + /// The userId who did the bulk action + user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade) + userId String? + + /// Why the user did the bulk action + reason String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -2002,7 +2026,7 @@ enum BulkActionStatus { model BulkActionItem { id String @id @default(cuid()) - friendlyId String @unique + friendlyId String? group BulkActionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade) groupId String From be683b9d32c92a8fa392fd2929a45ef8e4d6a159 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 12 Jun 2025 17:38:44 +0100 Subject: [PATCH 017/212] WIP prisma schema Removed extra runtimeEnvironmentId From 2648cbc195296fce095bbfc5166b50b5dd34e700 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 12 Jun 2025 17:38:44 +0100 Subject: [PATCH 018/212] WIP prisma schema Removed extra runtimeEnvironmentId From c5617ac7d6543e1c535f8061b43aece747016b0a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 16 Jun 2025 09:49:45 +0100 Subject: [PATCH 019/212] Migrations --- .../migration.sql | 36 +++++++++++++++++++ .../migration.sql | 2 ++ .../migration.sql | 4 +++ .../database/prisma/schema.prisma | 1 + 4 files changed, 43 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20250616083614_bulk_action_v2/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20250616084546_bulk_action_item_drop_friendly_id_unique_constraint/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20250616084735_bulk_action_item_friendly_id_optional/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250616083614_bulk_action_v2/migration.sql b/internal-packages/database/prisma/migrations/20250616083614_bulk_action_v2/migration.sql new file mode 100644 index 0000000000..54faaebc23 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250616083614_bulk_action_v2/migration.sql @@ -0,0 +1,36 @@ +-- AlterTable +ALTER TABLE "BulkActionGroup" +ADD COLUMN IF NOT EXISTS "cursor" JSONB, +ADD COLUMN IF NOT EXISTS "environmentId" TEXT, +ADD COLUMN IF NOT EXISTS "params" JSONB, +ADD COLUMN IF NOT EXISTS "processedCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS "queryName" TEXT, +ADD COLUMN IF NOT EXISTS "reason" TEXT, +ADD COLUMN IF NOT EXISTS "totalCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS "userId" TEXT; + +-- Add foreign key constraints if they don't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'BulkActionGroup_environmentId_fkey' + ) THEN + ALTER TABLE "BulkActionGroup" + ADD CONSTRAINT "BulkActionGroup_environmentId_fkey" + FOREIGN KEY ("environmentId") + REFERENCES "RuntimeEnvironment"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'BulkActionGroup_userId_fkey' + ) THEN + ALTER TABLE "BulkActionGroup" + ADD CONSTRAINT "BulkActionGroup_userId_fkey" + FOREIGN KEY ("userId") + REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250616084546_bulk_action_item_drop_friendly_id_unique_constraint/migration.sql b/internal-packages/database/prisma/migrations/20250616084546_bulk_action_item_drop_friendly_id_unique_constraint/migration.sql new file mode 100644 index 0000000000..e476fdd9df --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250616084546_bulk_action_item_drop_friendly_id_unique_constraint/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX CONCURRENTLY IF EXISTS "BulkActionItem_friendlyId_key"; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250616084735_bulk_action_item_friendly_id_optional/migration.sql b/internal-packages/database/prisma/migrations/20250616084735_bulk_action_item_friendly_id_optional/migration.sql new file mode 100644 index 0000000000..cd879e44d4 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250616084735_bulk_action_item_friendly_id_optional/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "BulkActionItem" +ALTER COLUMN "friendlyId" +DROP NOT NULL; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 004bdff43f..faa8a849a3 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2026,6 +2026,7 @@ enum BulkActionStatus { model BulkActionItem { id String @id @default(cuid()) + /// @deprecated not used in new BulkActions friendlyId String? group BulkActionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade) From b869345f9fa15967ed0db9c4cc058be1b2d0eff1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 16 Jun 2025 09:49:45 +0100 Subject: [PATCH 020/212] Migrations From 7557871405c905136744726073c8628d5051ff4e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 16 Jun 2025 09:49:45 +0100 Subject: [PATCH 021/212] Migrations From 13f44c66797413ff79a72e2e8df5411662b5b9f6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 23 Jun 2025 15:14:11 +0100 Subject: [PATCH 022/212] BulkActionGroup changed some columns around --- .../migration.sql | 18 +++++++++++++++ .../database/prisma/schema.prisma | 22 ++++++++++++------- 2 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250623141255_bulk_action_group_counts_completed_at/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250623141255_bulk_action_group_counts_completed_at/migration.sql b/internal-packages/database/prisma/migrations/20250623141255_bulk_action_group_counts_completed_at/migration.sql new file mode 100644 index 0000000000..35287c3bf9 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250623141255_bulk_action_group_counts_completed_at/migration.sql @@ -0,0 +1,18 @@ +/* +Warnings: + +- You are about to drop the column `processedCount` on the `BulkActionGroup` table. All the data in the column will be lost. +- You are about to drop the column `reason` on the `BulkActionGroup` table. All the data in the column will be lost. + + */ +-- AlterEnum +ALTER TYPE "BulkActionStatus" ADD VALUE 'ABORTED'; + +-- AlterTable +ALTER TABLE "BulkActionGroup" +DROP COLUMN "processedCount", +DROP COLUMN "reason", +ADD COLUMN "completedAt" TIMESTAMP(3), +ADD COLUMN "failureCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "name" TEXT, +ADD COLUMN "successCount" INTEGER NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index faa8a849a3..507323c07e 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1990,22 +1990,27 @@ model BulkActionGroup { status BulkActionStatus @default(PENDING) /// The query that will be executed to get the runs to process (if null, we are passing run ids directly) - queryName String? + queryName String? /// The params that will be passed to the query - params Json? + params Json? /// The cursor that will be passed to the query (null for the first page) - cursor Json? - /// The number of runs that have been processed - processedCount Int @default(0) + cursor Json? + /// The number of runs that have been processed successfully + successCount Int @default(0) + /// The number of runs that have been failed + failureCount Int @default(0) /// The total number of runs that will be processed - totalCount Int @default(0) + totalCount Int @default(0) /// The userId who did the bulk action user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade) userId String? - /// Why the user did the bulk action - reason String? + /// The name of the bulk action + name String? + + /// The time the bulk action was completed + completedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -2021,6 +2026,7 @@ enum BulkActionType { enum BulkActionStatus { PENDING COMPLETED + ABORTED } model BulkActionItem { From 413476a4601a649140427c583532bc94edaeb2a6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 23 Jun 2025 15:14:11 +0100 Subject: [PATCH 023/212] BulkActionGroup changed some columns around From aea732fb75993a0f5a48dd41275f90caacff150b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 23 Jun 2025 15:14:11 +0100 Subject: [PATCH 024/212] BulkActionGroup changed some columns around From aa2fdfca1d0f198116967baaebee2b63a9eb49ce Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:56:00 +0100 Subject: [PATCH 025/212] New badge variant, removed unused ones --- apps/webapp/app/components/primitives/Badge.tsx | 6 ++---- apps/webapp/app/routes/storybook.badges/route.tsx | 5 +---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index 04a033ba02..861ce1ff04 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -4,14 +4,12 @@ import { cn } from "~/utils/cn"; const variants = { default: "grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-charcoal-750 text-text-bright uppercase whitespace-nowrap", - small: - "grid place-items-center rounded-full px-[0.4rem] h-4 tracking-wider text-xxs bg-background-dimmed text-text-dimmed uppercase whitespace-nowrap", "extra-small": "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 whitespace-nowrap", - outline: - "grid place-items-center rounded-sm px-1.5 h-5 tracking-wider text-xxs border border-dimmed text-text-dimmed uppercase whitespace-nowrap", "outline-rounded": "grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap", + rounded: + "grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-text-bright uppercase whitespace-nowrap", }; type BadgeProps = React.HTMLAttributes & { diff --git a/apps/webapp/app/routes/storybook.badges/route.tsx b/apps/webapp/app/routes/storybook.badges/route.tsx index ba705d1d72..3b45b78515 100644 --- a/apps/webapp/app/routes/storybook.badges/route.tsx +++ b/apps/webapp/app/routes/storybook.badges/route.tsx @@ -4,10 +4,7 @@ export default function Story() { return (
Default -
- Small -
- Outline + 3 Outline rounded
); From 498fb312f4caa54bb84caa80a26132b01b273237 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:56:00 +0100 Subject: [PATCH 026/212] New badge variant, removed unused ones From 06ac7167ade90d6509247c0644b4ff5348ce7742 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:56:00 +0100 Subject: [PATCH 027/212] New badge variant, removed unused ones From faf210180c700c8e9a9b850d492a55a1012ebdfb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:56:14 +0100 Subject: [PATCH 028/212] Bulk action button --- .../app/assets/icons/ListCheckedIcon.tsx | 48 +++++++++++++++++++ .../route.tsx | 33 ++++++++----- 2 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 apps/webapp/app/assets/icons/ListCheckedIcon.tsx diff --git a/apps/webapp/app/assets/icons/ListCheckedIcon.tsx b/apps/webapp/app/assets/icons/ListCheckedIcon.tsx new file mode 100644 index 0000000000..29cb828f5d --- /dev/null +++ b/apps/webapp/app/assets/icons/ListCheckedIcon.tsx @@ -0,0 +1,48 @@ +export function ListCheckedIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} 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 5f1fa3e7a7..51264dd1a3 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 @@ -67,6 +67,8 @@ import { import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; import { CreateBulkActionInspector } from "../resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { Badge } from "~/components/primitives/Badge"; export const meta: MetaFunction = () => { return [ @@ -196,17 +198,26 @@ export default function Page() { rootOnlyDefault={rootOnlyDefault} />
- - Bulk action - + {!isShowingBulkActionInspector && ( + 0 ? "pr-1" : undefined} + > + + Bulk action + {selectedItems.size > 0 && ( + {selectedItems.size} + )} + + + )}
From 866d78affe967b43d7dfb7041a2a045827a38054 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:56:14 +0100 Subject: [PATCH 029/212] Bulk action button From 32fdfb8e47ef5a22cfcd974446fc85469c5569f4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 26 Jun 2025 17:56:14 +0100 Subject: [PATCH 030/212] Bulk action button From cc519e8d7c77c512f1464a516ffb48100d4a91d3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 30 Jun 2025 17:53:56 +0100 Subject: [PATCH 031/212] Make the next runs page the default now --- .../route.tsx | 506 ------------------ .../route.tsx | 10 - .../route.tsx | 242 ++++----- ...onments.$environmentId.runs.bulkaction.tsx | 19 +- apps/webapp/app/utils/pathBuilder.ts | 13 +- 5 files changed, 132 insertions(+), 658 deletions(-) delete mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx delete mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs/route.tsx 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 deleted file mode 100644 index 51264dd1a3..0000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx +++ /dev/null @@ -1,506 +0,0 @@ -import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid"; -import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid"; -import { Form, type MetaFunction, useNavigation } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { IconCircleX } from "@tabler/icons-react"; -import { AnimatePresence, motion } from "framer-motion"; -import { ListChecks, ListX } from "lucide-react"; -import { Suspense, useState } from "react"; -import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; -import { TaskIcon } from "~/assets/icons/TaskIcon"; -import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence"; -import { StepContentContainer } from "~/components/StepContentContainer"; -import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTrigger, -} from "~/components/primitives/Dialog"; -import { Header1, Header2 } from "~/components/primitives/Headers"; -import { InfoPanel } from "~/components/primitives/InfoPanel"; -import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { - SelectedItemsProvider, - useSelectedItems, -} from "~/components/primitives/SelectedItemsProvider"; -import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; -import { StepNumber } from "~/components/primitives/StepNumber"; -import { TextLink } from "~/components/primitives/TextLink"; -import { RunsFilters, getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; -import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; -import { BULK_ACTION_RUN_LIMIT } from "~/consts"; -import { $replica } from "~/db.server"; -import { useEnvironment } from "~/hooks/useEnvironment"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { findProjectBySlug } from "~/models/project.server"; -import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; -import { clickhouseClient } from "~/services/clickhouseInstance.server"; -import { - getRootOnlyFilterPreference, - setRootOnlyFilterPreference, - uiPreferencesStorage, -} from "~/services/preferences/uiPreferences.server"; -import { requireUserId } from "~/services/session.server"; -import { cn } from "~/utils/cn"; -import { - docsPath, - EnvironmentParamSchema, - v3CreateBulkActionPath, - v3ProjectPath, - v3RunsNextPath, - 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"; -import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; -import { Badge } from "~/components/primitives/Badge"; - -export const meta: MetaFunction = () => { - return [ - { - title: `Runs | Trigger.dev`, - }, - ]; -}; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Error("Project not found"); - } - - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - throw new Error("Environment not found"); - } - - 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, - ...filters, - }); - - const session = await setRootOnlyFilterPreference(filters.rootOnly, request); - const cookieValue = await uiPreferencesStorage.commitSession(session); - - return typeddefer( - { - data: list, - rootOnlyDefault: filters.rootOnly, - filters, - }, - { - headers: { - "Set-Cookie": cookieValue, - }, - } - ); -}; - -export default function Page() { - 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 ( - <> - - - {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - - )} - - - Runs docs - - - - - - {({ selectedItems }) => ( - - -
- -
- - Loading runs -
-
- } - > - - {(list) => ( - <> - {list.runs.length === 0 && !list.hasAnyRuns ? ( - list.possibleTasks.length === 0 ? ( - - ) : ( - - ) - ) : ( -
-
- -
- {!isShowingBulkActionInspector && ( - 0 ? "pr-1" : undefined} - > - - Bulk action - {selectedItems.size > 0 && ( - {selectedItems.size} - )} - - - )} - -
-
- - -
- )} - - )} -
- - - -
- {isShowingBulkActionInspector && ( - <> - - - - - - )} -
- )} -
-
- - ); -} - -function BulkActionBar() { - const { selectedItems, deselectAll } = useSelectedItems(); - const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none"); - - const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT; - - return ( - - {selectedItems.size > 0 && ( - -
- - Bulk actions: - {hasSelectedMaximum ? ( - - Maximum of {selectedItems.size} runs selected - - ) : ( - {selectedItems.size} runs selected - )} -
-
- { - if (o) { - setBarState("cancel"); - } else { - setBarState("none"); - } - }} - /> - { - if (o) { - setBarState("replay"); - } else { - setBarState("none"); - } - }} - /> - -
-
- )} -
- ); -} - -function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) { - const { selectedItems } = useSelectedItems(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const failedRedirect = v3RunsNextPath(organization, project, environment); - - const formAction = `/resources/taskruns/bulk/cancel`; - - const navigation = useNavigation(); - const isLoading = navigation.formAction === formAction; - - return ( - onOpen(o)}> - - - - - Cancel {selectedItems.size} runs? - - Canceling these runs will stop them from running. Only runs that are not already finished - will be canceled, the others will remain in their existing state. - - -
- - - - - {[...selectedItems].map((runId) => ( - - ))} - -
-
-
-
- ); -} - -function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) { - const { selectedItems } = useSelectedItems(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const failedRedirect = v3RunsNextPath(organization, project, environment); - - const formAction = `/resources/taskruns/bulk/replay`; - - const navigation = useNavigation(); - const isLoading = navigation.formAction === formAction; - - return ( - onOpen(o)}> - - - - - Replay runs? - - Replaying these runs will create a new run for each with the same payload and environment - as the original. It will use the latest version of the code for each task. - - -
- - - - - {[...selectedItems].map((runId) => ( - - ))} - -
-
-
-
- ); -} - -function CreateFirstTaskInstructions() { - const organization = useOrganization(); - const project = useProject(); - return ( - - - Create a task - - } - > - - Before running a task, you must first create one. Follow the instructions on the{" "} - Tasks page to create a - task, then return here to run it. - - - - ); -} - -function RunTaskInstructions() { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - return ( - - How to run your tasks - - - - Perform a test run with a payload directly from the dashboard. - - - Test - -
-
- OR -
-
-
- - - - - Performing a real run depends on the type of trigger your task is using. - - - How to trigger a task - - -
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs/route.tsx deleted file mode 100644 index f6723ddeba..0000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs/route.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Outlet } from "@remix-run/react"; -import { PageContainer } from "~/components/layout/AppLayout"; - -export default function Page() { - return ( - - - - ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 294b1a2ca8..090a7ef291 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -31,15 +31,17 @@ import { import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; -import { RunsFilters, TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { RunsFilters, getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; +import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { RunListPresenter } from "~/presenters/v3/RunListPresenter.server"; +import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { getRootOnlyFilterPreference, setRootOnlyFilterPreference, @@ -50,11 +52,24 @@ import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema, + v3CreateBulkActionPath, v3ProjectPath, + v3RunsNextPath, v3RunsPath, 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"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { Badge } from "~/components/primitives/Badge"; export const meta: MetaFunction = () => { return [ @@ -68,15 +83,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"); @@ -87,67 +93,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 RunListPresenter(); - const list = presenter.call(environment.id, { + 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: { @@ -158,12 +124,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 ( <> @@ -188,65 +158,97 @@ 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 && ( + 0 ? "pr-1" : undefined} + > + + Bulk action + {selectedItems.size > 0 && ( + {selectedItems.size} + )} + + + )} + +
+
+ + +
+ )} + )} - - )} -
- - -
+
+ + + + + {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 index 930515c6b8..0bf24c9175 100644 --- 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 @@ -1,6 +1,6 @@ import { ArrowPathIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/24/outline"; -import { Form, useActionData, useFetcher } from "@remix-run/react"; +import { Form } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; import { useEffect } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; @@ -8,8 +8,13 @@ import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Fieldset } from "~/components/primitives/Fieldset"; -import { InputGroup } from "~/components/primitives/InputGroup"; import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { $replica, type PrismaClient } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; @@ -23,14 +28,8 @@ import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { RunsRepository } from "~/services/runsRepository.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; -import { v3RunsNextPath, v3RunsPath } from "~/utils/pathBuilder"; -import { Input } from "~/components/primitives/Input"; -import { Label } from "~/components/primitives/Label"; -import { Hint } from "~/components/primitives/Hint"; -import { RadioGroupItem, RadioGroup } from "~/components/primitives/RadioButton"; import { formatNumber } from "~/utils/numberFormatter"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { formatDateTime } from "~/components/primitives/DateTime"; +import { v3RunsPath } from "~/utils/pathBuilder"; const Params = z.object({ organizationId: z.string(), @@ -129,7 +128,7 @@ export function CreateBulkActionInspector({
Create a bulk action Date: Mon, 30 Jun 2025 17:53:56 +0100 Subject: [PATCH 032/212] Make the next runs page the default now From 1a8124725d342572f7f22c658baa6f9f381d3833 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 30 Jun 2025 17:53:56 +0100 Subject: [PATCH 033/212] Make the next runs page the default now From 0224b9f3c881b314963ad7c5a9ce556fe315ee66 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 30 Jun 2025 18:32:29 +0100 Subject: [PATCH 034/212] Improved the RadioButton style --- .../webapp/app/components/primitives/RadioButton.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx index 928f587afd..90418e62d3 100644 --- a/apps/webapp/app/components/primitives/RadioButton.tsx +++ b/apps/webapp/app/components/primitives/RadioButton.tsx @@ -39,7 +39,7 @@ const variants = { description: { button: "w-full p-2.5 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-600 border rounded-sm", - label: "text-text-bright font-semibold -mt-1 text-left", + label: "text-text-bright font-semibold -mt-1 text-left text-sm", description: "text-text-dimmed -mt-0 text-left", inputPosition: "mt-0", icon: "w-8 h-8 mb-2", @@ -77,12 +77,12 @@ export function RadioButtonCircle({ {checked && (
)} @@ -131,12 +131,12 @@ export const RadioGroupItem = React.forwardRef< >
- - + +
From 2cc42330b3fa8ac6b07d7fcc8135cab43de661d3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 30 Jun 2025 18:32:29 +0100 Subject: [PATCH 035/212] Improved the RadioButton style From 24159feee6e38ef91b9b336c7802f867b123488c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 30 Jun 2025 18:32:29 +0100 Subject: [PATCH 036/212] Improved the RadioButton style From 1cfbdab2c5286fcdd67e8c72ad1aa44a6c8618ef Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 30 Jun 2025 18:59:45 +0100 Subject: [PATCH 037/212] Remove the old bulk action bar --- .../route.tsx | 202 +----------------- 1 file changed, 10 insertions(+), 192 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 090a7ef291..6f43603171 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -7,10 +7,12 @@ import { AnimatePresence, motion } from "framer-motion"; import { ListChecks, ListX } from "lucide-react"; import { Suspense, useState } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence"; import { StepContentContainer } from "~/components/StepContentContainer"; import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Dialog, @@ -24,6 +26,11 @@ import { Header1, Header2 } from "~/components/primitives/Headers"; import { InfoPanel } from "~/components/primitives/InfoPanel"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; import { SelectedItemsProvider, useSelectedItems, @@ -31,19 +38,20 @@ import { import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; -import { RunsFilters, getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters"; +import { RunsFilters } from "~/components/runs/v3/RunFilters"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { - getRootOnlyFilterPreference, setRootOnlyFilterPreference, uiPreferencesStorage, } from "~/services/preferences/uiPreferences.server"; @@ -54,22 +62,11 @@ import { EnvironmentParamSchema, v3CreateBulkActionPath, v3ProjectPath, - v3RunsNextPath, v3RunsPath, 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"; -import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; -import { Badge } from "~/components/primitives/Badge"; export const meta: MetaFunction = () => { return [ @@ -237,7 +234,6 @@ export default function Page() { )} -
{isShowingBulkActionInspector && ( @@ -256,184 +252,6 @@ export default function Page() { ); } -function BulkActionBar() { - const { selectedItems, deselectAll } = useSelectedItems(); - const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none"); - - const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT; - - return ( - - {selectedItems.size > 0 && ( - -
- - Bulk actions: - {hasSelectedMaximum ? ( - - Maximum of {selectedItems.size} runs selected - - ) : ( - {selectedItems.size} runs selected - )} -
-
- { - if (o) { - setBarState("cancel"); - } else { - setBarState("none"); - } - }} - /> - { - if (o) { - setBarState("replay"); - } else { - setBarState("none"); - } - }} - /> - -
-
- )} -
- ); -} - -function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) { - const { selectedItems } = useSelectedItems(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const failedRedirect = v3RunsPath(organization, project, environment); - - const formAction = `/resources/taskruns/bulk/cancel`; - - const navigation = useNavigation(); - const isLoading = navigation.formAction === formAction; - - return ( - onOpen(o)}> - - - - - Cancel {selectedItems.size} runs? - - Canceling these runs will stop them from running. Only runs that are not already finished - will be canceled, the others will remain in their existing state. - - -
- - - - - {[...selectedItems].map((runId) => ( - - ))} - -
-
-
-
- ); -} - -function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) { - const { selectedItems } = useSelectedItems(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const failedRedirect = v3RunsPath(organization, project, environment); - - const formAction = `/resources/taskruns/bulk/replay`; - - const navigation = useNavigation(); - const isLoading = navigation.formAction === formAction; - - return ( - onOpen(o)}> - - - - - Replay runs? - - Replaying these runs will create a new run for each with the same payload and environment - as the original. It will use the latest version of the code for each task. - - -
- - - - - {[...selectedItems].map((runId) => ( - - ))} - -
-
-
-
- ); -} - function CreateFirstTaskInstructions() { const organization = useOrganization(); const project = useProject(); From 52e247ea08f71027349dab2aafc32fad3df023d4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 30 Jun 2025 18:59:45 +0100 Subject: [PATCH 038/212] Remove the old bulk action bar From 5e4ea1c475303caebe376c855382197963f51324 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 30 Jun 2025 18:59:45 +0100 Subject: [PATCH 039/212] Remove the old bulk action bar From 1b0acf54f90eb7910658e39d1e83afcafa4334d3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 1 Jul 2025 12:47:30 +0100 Subject: [PATCH 040/212] More UI progress --- .../app/components/runs/v3/RunFilters.tsx | 35 +++++ ...onments.$environmentId.runs.bulkaction.tsx | 146 ++++++++++++++++-- 2 files changed, 165 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index de8d9904e9..0fc6539552 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -106,6 +106,41 @@ export const TaskRunListSearchFilters = z.object({ export type TaskRunListSearchFilters = z.infer; +type FilterKey = keyof TaskRunListSearchFilters; + +export function filterTitle(filterKey: string) { + switch (filterKey) { + case "cursor": + return "Cursor"; + case "direction": + return "Direction"; + case "statuses": + return "Status"; + case "tasks": + return "Tasks"; + case "tags": + return "Tags"; + case "bulkId": + return "Bulk action"; + case "period": + return "Period"; + case "from": + return "From"; + case "to": + return "To"; + case "rootOnly": + return "Root only"; + case "batchId": + return "Batch ID"; + case "runId": + return "Run ID"; + case "scheduleId": + return "Schedule ID"; + default: + return filterKey; + } +} + export function getRunFiltersFromSearchParams( searchParams: URLSearchParams ): TaskRunListSearchFilters { 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 index 0bf24c9175..c004a38636 100644 --- 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 @@ -1,4 +1,5 @@ -import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import simplur from "simplur"; +import { ArrowPathIcon, CheckIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/24/outline"; import { Form } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; @@ -6,6 +7,7 @@ import { useEffect } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Fieldset } from "~/components/primitives/Fieldset"; import { Header2 } from "~/components/primitives/Headers"; @@ -13,9 +15,11 @@ import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { filterTitle, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { appliedSummary } from "~/components/runs/v3/SharedFilters"; import { $replica, type PrismaClient } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; @@ -37,9 +41,14 @@ const Params = z.object({ environmentId: z.string(), }); +const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]); +type BulkActionMode = z.infer; +const BulkActionAction = z.union([z.literal("cancel"), z.literal("replay")]); +type BulkActionAction = z.infer; + 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"), + mode: BulkActionMode.default("filter"), + action: BulkActionAction.default("cancel"), }); export async function loader({ request, params }: LoaderFunctionArgs) { @@ -94,7 +103,7 @@ export function CreateBulkActionInspector({ const project = useProject(); const environment = useEnvironment(); const fetcher = useTypedFetcher(); - const { value } = useSearchParams(); + const { value, replace } = useSearchParams(); const location = useOptimisticLocation(); useEffect(() => { @@ -108,16 +117,12 @@ export function CreateBulkActionInspector({ const data = fetcher.data != null ? fetcher.data : undefined; - const formattedFilteredRunsCount = - data?.count !== undefined ? ( - `~${formatNumber(data.count)}` - ) : ( - - ); - const closedSearchParams = new URLSearchParams(location.search); closedSearchParams.delete("bulkInspector"); + const impactedCount = + mode === "selected" ? selectedItems.size : ; + return (
{ + replace({ mode: value }); + }} > All {formattedFilteredRunsCount} runs matching your filters} + label={ + + All runs matching your filters + + } value={"filter"} variant="button/small" /> { + replace({ action: value }); + }} > + + + +
@@ -217,11 +241,12 @@ export function CreateBulkActionInspector({ key: "enter", enabledOnInputElements: true, }} + disabled={impactedCount === 0} > {action === "replay" ? ( - Replay {formattedFilteredRunsCount} runs… + Replay {impactedCount} runs… ) : ( - Cancel {formattedFilteredRunsCount} runs… + Cancel {impactedCount} runs… )}
@@ -229,3 +254,92 @@ export function CreateBulkActionInspector({ ); } + +function BulkActionPreview({ + selected, + mode, + action, + filters, +}: { + selected?: number; + mode: BulkActionMode; + action: BulkActionAction; + filters: TaskRunListSearchFilters; +}) { + switch (mode) { + case "selected": + return ( + + You have individually selected {simplur`${selected} run[|s]`} to be{" "} + . + + ); + case "filter": + return ( +
+ + You have selected runs to be{" "} + using these filters: + +
+ {Object.entries(filters).map(([key, value]) => { + if (!value) { + return null; + } + + const title = filterTitle(key); + const valueString = + typeof value === "boolean" ? ( + value ? ( + + ) : ( + "No" + ) + ) : Array.isArray(value) ? ( + appliedSummary(value) + ) : ( + value + ); + + return ( + + ); + })} +
+
+ ); + } +} + +function Action({ action }: { action: BulkActionAction }) { + switch (action) { + case "cancel": + return ( + <> + + Canceled + + ); + case "replay": + return ( + <> + + Replayed + + ); + } +} + +function EstimatedCount({ count }: { count?: number }) { + if (typeof count === "number") { + return <>~{formatNumber(count)}; + } + + return ; +} From f8444b3e65b05386ea47b1811895fe577b9dc3b0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 1 Jul 2025 12:47:30 +0100 Subject: [PATCH 041/212] More UI progress From e00a2460c82cd7dd08d0d5ba1170aee773cfe93b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 1 Jul 2025 12:47:30 +0100 Subject: [PATCH 042/212] More UI progress From c45f4063972d94b8a8c3fe1d43ccf7711a796c32 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:20:59 +0100 Subject: [PATCH 043/212] Lots of UI changes to the Runs page --- apps/webapp/app/components/ListPagination.tsx | 14 +- .../components/primitives/AppliedFilter.tsx | 23 +- .../app/components/primitives/Pagination.tsx | 12 +- .../app/components/primitives/Select.tsx | 9 +- .../app/components/primitives/Switch.tsx | 12 ++ .../app/components/runs/v3/RunFilters.tsx | 62 +++++- .../app/components/runs/v3/SharedFilters.tsx | 66 ++++-- .../route.tsx | 4 +- ...onments.$environmentId.runs.bulkaction.tsx | 204 +++++++++++++++--- 9 files changed, 323 insertions(+), 83 deletions(-) diff --git a/apps/webapp/app/components/ListPagination.tsx b/apps/webapp/app/components/ListPagination.tsx index 56a5c03380..0fc1e76d51 100644 --- a/apps/webapp/app/components/ListPagination.tsx +++ b/apps/webapp/app/components/ListPagination.tsx @@ -29,9 +29,8 @@ function NextButton({ cursor }: { cursor?: string }) { return ( - Next - + /> ); } @@ -52,9 +49,8 @@ function PreviousButton({ cursor }: { cursor?: string }) { return ( - Prev - + /> ); } diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx index c67cc82a9e..d03668e936 100644 --- a/apps/webapp/app/components/primitives/AppliedFilter.tsx +++ b/apps/webapp/app/components/primitives/AppliedFilter.tsx @@ -1,21 +1,26 @@ import { XMarkIcon } from "@heroicons/react/20/solid"; -import { ReactNode } from "react"; +import { type ReactNode } from "react"; import { cn } from "~/utils/cn"; const variants = { + "secondary/small": { + box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-charcoal-600 group-hover:border-charcoal-550 text-text-bright border border-charcoal-600", + clear: "size-6 text-text-bright hover:text-text-bright transition-colors", + }, "tertiary/small": { box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-charcoal-600", clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors", }, - "minimal/small": { - box: "h-6 hover:bg-tertiary rounded pl-1.5 gap-1.5 text-xs", - clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors", + "minimal/medium": { + box: "h-6 rounded gap-1.5 text-sm", + clear: "size-6 text-text-dimmed transition-colors", }, }; type Variant = keyof typeof variants; type AppliedFilterProps = { + icon?: ReactNode; label: ReactNode; value: ReactNode; removable?: boolean; @@ -25,6 +30,7 @@ type AppliedFilterProps = { }; export function AppliedFilter({ + icon, label, value, removable = true, @@ -43,10 +49,13 @@ export function AppliedFilter({ )} >
-
- {label}: +
+ {icon} +
+ {label}: +
-
+
{value}
diff --git a/apps/webapp/app/components/primitives/Pagination.tsx b/apps/webapp/app/components/primitives/Pagination.tsx index 20a1a93be2..186878c6d7 100644 --- a/apps/webapp/app/components/primitives/Pagination.tsx +++ b/apps/webapp/app/components/primitives/Pagination.tsx @@ -22,15 +22,13 @@ export function PaginationControls({ ); } diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index fa8a63603b..82f750c42e 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -28,9 +28,16 @@ const style = { button: "bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none", }, + secondary: { + button: + "bg-secondary focus-custom border border-charcoal-600 hover:text-text-bright hover:border-charcoal-550 text-text-bright hover:bg-charcoal-600", + }, }; const variants = { + "secondary/small": { + button: cn(sizes.small.button, style.secondary.button), + }, "tertiary/small": { button: cn(sizes.small.button, style.tertiary.button), }, @@ -595,7 +602,7 @@ export interface SelectHeadingProps extends Ariakit.SelectHeadingProps {} export function SelectHeading({ render, ...props }: SelectHeadingProps) { return (
- {render}} /> +
); } diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 68c967b92d..88a2417aa2 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -33,6 +33,18 @@ const variations = { "transition group-hover:text-text-bright group-disabled:group-hover:text-text-dimmed" ), }, + "secondary/small": { + container: cn( + small.container, + "border border-charcoal-600 hover:border-charcoal-550 bg-secondary hover:bg-charcoal-600" + ), + root: cn( + small.root, + "group-data-[state=unchecked]:bg-secondary group-data-[state=unchecked]:group-hover:bg-secondary/50" + ), + thumb: small.thumb, + text: cn(small.text, "transition text-text-bright group-disabled:group-hover:text-text-dimmed"), + }, medium: { container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary py-1.5 px-2 transition focus-custom", diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 0fc6539552..e3a5e5a9f6 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1,12 +1,14 @@ import * as Ariakit from "@ariakit/react"; import { + CalendarIcon, ClockIcon, FingerPrintIcon, Squares2X2Icon, TagIcon, - TrashIcon, + XMarkIcon, } from "@heroicons/react/20/solid"; import { Form, useFetcher } from "@remix-run/react"; +import { IconToggleLeft } from "@tabler/icons-react"; import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database"; import { ListChecks, ListFilterIcon } from "lucide-react"; import { matchSorter } from "match-sorter"; @@ -105,8 +107,7 @@ export const TaskRunListSearchFilters = z.object({ }); export type TaskRunListSearchFilters = z.infer; - -type FilterKey = keyof TaskRunListSearchFilters; +export type TaskRunListSearchFilterKey = keyof TaskRunListSearchFilters; export function filterTitle(filterKey: string) { switch (filterKey) { @@ -141,6 +142,38 @@ export function filterTitle(filterKey: string) { } } +export function filterIcon(filterKey: string): ReactNode | undefined { + switch (filterKey) { + case "cursor": + case "direction": + return undefined; + case "statuses": + return ; + case "tasks": + return ; + case "tags": + return ; + case "bulkId": + return ; + case "period": + return ; + case "from": + return ; + case "to": + return ; + case "rootOnly": + return ; + case "batchId": + return ; + case "runId": + return ; + case "scheduleId": + return ; + default: + return undefined; + } +} + export function getRunFiltersFromSearchParams( searchParams: URLSearchParams ): TaskRunListSearchFilters { @@ -212,9 +245,7 @@ export function RunsFilters(props: RunFiltersProps) { {searchParams.has("rootOnly") && ( )} - +
@@ -249,7 +280,7 @@ function FilterMenu(props: RunFiltersProps) {
} - variant={"tertiary/small"} + variant={"secondary/small"} shortcut={shortcut} tooltipTitle={"Filter runs"} > @@ -433,8 +464,10 @@ function AppliedStatusFilter() { }> runStatusTitle(v as TaskRunStatus)))} onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" /> } @@ -520,6 +553,7 @@ function AppliedTaskFilter({ possibleTasks }: Pick}> { const task = possibleTasks.find((task) => task.slug === v); @@ -527,6 +561,7 @@ function AppliedTaskFilter({ possibleTasks }: Pick del(["tasks", "cursor", "direction"])} + variant="secondary/small" /> } @@ -618,8 +653,10 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick}> del(["bulkId", "cursor", "direction"])} + variant="secondary/small" /> } @@ -740,8 +777,10 @@ function AppliedTagsFilter() { }> del(["tags", "cursor", "direction"])} + variant="secondary/small" /> } @@ -768,10 +807,9 @@ function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) { return ( { replace({ rootOnly: checked ? "true" : "false", @@ -886,8 +924,10 @@ function AppliedRunIdFilter() { }> del(["runId", "cursor", "direction"])} + variant="secondary/small" /> } @@ -1004,8 +1044,10 @@ function AppliedBatchIdFilter() { }> del(["batchId", "cursor", "direction"])} + variant="secondary/small" /> } @@ -1122,8 +1164,10 @@ function AppliedScheduleIdFilter() { }> del(["scheduleId", "cursor", "direction"])} + variant="secondary/small" /> } diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index 5b7478d6a1..e36259e4e5 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -10,6 +10,7 @@ import { Label } from "~/components/primitives/Label"; import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select"; import { useSearchParams } from "~/hooks/useSearchParam"; import { Button } from "../../primitives/Buttons"; +import { filterIcon } from "./RunFilters"; export type DisplayableEnvironment = Pick & { userName?: string; @@ -100,6 +101,8 @@ if (!defaultPeriodMs) { throw new Error("Invalid default period"); } +type TimeRangeType = "period" | "range" | "from" | "to"; + export const timeFilters = ({ period, from, @@ -108,16 +111,27 @@ export const timeFilters = ({ period?: string; from?: string | number; to?: string | number; -}): { period?: string; from?: Date; to?: Date; isDefault: boolean } => { +}): { + period?: string; + from?: Date; + to?: Date; + isDefault: boolean; + rangeType: TimeRangeType; + label: string; + valueLabel: ReactNode; +} => { if (period) { - return { period, isDefault: period === defaultPeriod }; + return { period, isDefault: period === defaultPeriod, ...timeFilterRenderValues({ period }) }; } if (from && to) { + const fromDate = typeof from === "string" ? dateFromString(from) : new Date(from); + const toDate = typeof to === "string" ? dateFromString(to) : new Date(to); return { - from: typeof from === "string" ? dateFromString(from) : new Date(from), - to: typeof to === "string" ? dateFromString(to) : new Date(to), + from: fromDate, + to: toDate, isDefault: false, + ...timeFilterRenderValues({ from: fromDate, to: toDate }), }; } @@ -127,6 +141,7 @@ export const timeFilters = ({ return { from: fromDate, isDefault: false, + ...timeFilterRenderValues({ from: fromDate }), }; } @@ -136,25 +151,28 @@ export const timeFilters = ({ return { to: toDate, isDefault: false, + ...timeFilterRenderValues({ to: toDate }), }; } return { period: defaultPeriod, isDefault: true, + ...timeFilterRenderValues({ period: defaultPeriod }), }; }; -export function TimeFilter() { - const { value, del } = useSearchParams(); - - const { period, from, to } = timeFilters({ - period: value("period"), - from: value("from"), - to: value("to"), - }); +export function timeFilterRenderValues({ + from, + to, + period, +}: { + from?: Date; + to?: Date; + period?: string; +}) { + const rangeType: TimeRangeType = from && to ? "range" : from ? "from" : to ? "to" : "period"; - const rangeType = from && to ? "range" : from ? "from" : to ? "to" : "period"; let valueLabel: ReactNode; switch (rangeType) { case "period": @@ -183,13 +201,31 @@ export function TimeFilter() { ? "Created after" : "Created before"; + return { label, valueLabel, rangeType }; +} + +export function TimeFilter() { + const { value, del } = useSearchParams(); + + const { period, from, to, label, valueLabel } = timeFilters({ + period: value("period"), + from: value("from"), + to: value("to"), + }); + return ( {() => ( }> - + } period={period} @@ -347,7 +383,7 @@ export function appliedSummary(values: string[], maxValues = 3) { return values.join(", "); } -function dateFromString(value: string | undefined | null): Date | undefined { +export function dateFromString(value: string | undefined | null): Date | undefined { if (!value) return; //is it an int? diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 6f43603171..9dd775de1e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -198,7 +198,7 @@ export default function Page() {
{!isShowingBulkActionInspector && ( 0 ? "pr-1" : undefined} > - + Bulk action {selectedItems.size > 0 && ( {selectedItems.size} 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 index c004a38636..3989682c97 100644 --- 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 @@ -1,10 +1,13 @@ -import simplur from "simplur"; import { ArrowPathIcon, CheckIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/24/outline"; import { Form } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; +import { type TaskRunStatus } from "@trigger.dev/database"; +import assertNever from "assert-never"; +import { filter } from "compression"; import { useEffect } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; +import simplur from "simplur"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; @@ -18,8 +21,18 @@ import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { filterTitle, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; -import { appliedSummary } from "~/components/runs/v3/SharedFilters"; +import { + filterIcon, + filterTitle, + type TaskRunListSearchFilterKey, + type TaskRunListSearchFilters, +} from "~/components/runs/v3/RunFilters"; +import { + appliedSummary, + dateFromString, + timeFilterRenderValues, +} from "~/components/runs/v3/SharedFilters"; +import { runStatusTitle } from "~/components/runs/v3/TaskRunStatus"; import { $replica, type PrismaClient } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; @@ -138,7 +151,7 @@ export function CreateBulkActionInspector({ project, environment )}?${closedSearchParams.toString()}`} - variant="minimal/small" + variant="minimal/medium" TrailingIcon={ExitIcon} shortcut={{ key: "esc" }} shortcutPosition="before-trailing-icon" @@ -161,7 +174,8 @@ export function CreateBulkActionInspector({ id="mode-filter" label={ - All runs matching your filters + {data?.count === 0 ? "" : "All"} runs + matching your filters } value={"filter"} @@ -274,46 +288,174 @@ function BulkActionPreview({ . ); - case "filter": + case "filter": { + const { label, valueLabel, rangeType } = timeFilterRenderValues({ + from: filters.from ? dateFromString(`${filters.from}`) : undefined, + to: filters.to ? dateFromString(`${filters.to}`) : undefined, + period: filters.period, + }); + return (
- You have selected runs to be{" "} - using these filters: + You have selected{" "} + + + {" "} + runs to be using these filters:
+ {Object.entries(filters).map(([key, value]) => { - if (!value) { + if (!value && key !== "period") { return null; } - const title = filterTitle(key); - const valueString = - typeof value === "boolean" ? ( - value ? ( - - ) : ( - "No" - ) - ) : Array.isArray(value) ? ( - appliedSummary(value) - ) : ( - value - ); + const typedKey = key as TaskRunListSearchFilterKey; - return ( - - ); + switch (typedKey) { + case "cursor": + case "direction": + case "environments": + //We need to handle time differently because we have a default + case "period": + case "from": + case "to": { + return null; + } + case "tasks": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "versions": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "statuses": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + runStatusTitle(v as TaskRunStatus)))} + removable={false} + /> + ); + } + case "tags": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "bulkId": { + return ( + + ); + } + case "rootOnly": { + return ( + + ) : ( + + ) + } + removable={false} + /> + ); + } + case "runId": { + return ( + + ); + } + case "batchId": { + return ( + + ); + } + case "scheduleId": { + return ( + + ); + } + default: { + assertNever(typedKey); + } + } })}
); + } } } From e74be668753e90c50a45b57af363e57f0d93822c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:20:59 +0100 Subject: [PATCH 044/212] Lots of UI changes to the Runs page From 4637981acb3267614b7bf04766db8ec176bd2511 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:20:59 +0100 Subject: [PATCH 045/212] Lots of UI changes to the Runs page From 91c1ba261b4d0f711ec0cea618e7abdf2d0c2ff9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:42:12 +0100 Subject: [PATCH 046/212] Fixed period filter resetting everything --- .../app/components/runs/v3/SharedFilters.tsx | 24 +++++++++++++------ apps/webapp/app/hooks/useSearchParam.ts | 9 +++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index e36259e4e5..51a21c8bd5 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -266,17 +266,17 @@ export function TimeDropdown({ }, [fromValue, toValue, replace]); const handlePeriodClick = useCallback((period: string) => { - setFromValue(undefined); - setToValue(undefined); - replace({ - period: period, + period, cursor: undefined, direction: undefined, from: undefined, to: undefined, }); + setFromValue(undefined); + setToValue(undefined); + setOpen(false); }, []); @@ -302,8 +302,12 @@ export function TimeDropdown({ ? "border-indigo-500 group-hover/button:border-indigo-500" : undefined } - onClick={() => handlePeriodClick(p.value)} + onClick={(e) => { + e.preventDefault(); + handlePeriodClick(p.value); + }} fullWidth + type="button" > {p.label} @@ -343,11 +347,13 @@ export function TimeDropdown({
@@ -359,7 +365,11 @@ export function TimeDropdown({ enabledOnInputElements: true, }} disabled={!fromValue && !toValue} - onClick={() => apply()} + onClick={(e) => { + e.preventDefault(); + apply(); + }} + type="button" > Apply diff --git a/apps/webapp/app/hooks/useSearchParam.ts b/apps/webapp/app/hooks/useSearchParam.ts index d7340db095..b8a49b20f4 100644 --- a/apps/webapp/app/hooks/useSearchParam.ts +++ b/apps/webapp/app/hooks/useSearchParam.ts @@ -27,16 +27,18 @@ export function useSearchParams() { search.append(param, v); } } + + return search; }, [location, search] ); const replace = useCallback( (values: Values) => { - set(values); - navigate(`${location.pathname}?${search.toString()}`, { replace: true }); + const s = set(values); + navigate(`${location.pathname}?${s.toString()}`, { replace: true }); }, - [location, search] + [location, search, set] ); const del = useCallback( @@ -82,7 +84,6 @@ export function useSearchParams() { return { value, values, - set, replace, del, has, From 2cc8f1a1464efdd7f88e13c9b20397db0998b0ed Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:42:12 +0100 Subject: [PATCH 047/212] Fixed period filter resetting everything From dabb78b7f45e2afb14e2de20fba73c19fe1c6dc6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:42:12 +0100 Subject: [PATCH 048/212] Fixed period filter resetting everything From 5c421ad22cfbc07e1b14d262e79197d63db79ebb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:45:15 +0100 Subject: [PATCH 049/212] Improved the Switch secondary style --- apps/webapp/app/components/primitives/Switch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 88a2417aa2..0a66ea8e30 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -40,7 +40,7 @@ const variations = { ), root: cn( small.root, - "group-data-[state=unchecked]:bg-secondary group-data-[state=unchecked]:group-hover:bg-secondary/50" + "group-data-[state=unchecked]:bg-charcoal-800 group-data-[state=unchecked]:group-hover:bg-charcoal-800/50" ), thumb: small.thumb, text: cn(small.text, "transition text-text-bright group-disabled:group-hover:text-text-dimmed"), From b17251d7a0410811fd842fff8607f0e6bfebe17c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:45:15 +0100 Subject: [PATCH 050/212] Improved the Switch secondary style From c0702ba5c8e0c9c97c4877cc48414330b58b69dc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 15:45:15 +0100 Subject: [PATCH 051/212] Improved the Switch secondary style From 0bff00137423157d9cef6dbab612ddd83fb785ea Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 17:00:23 +0100 Subject: [PATCH 052/212] Buggy filter fixes --- .../app/components/runs/v3/BatchFilters.tsx | 21 ++++-- .../app/components/runs/v3/SharedFilters.tsx | 31 +++++---- apps/webapp/app/hooks/useSearchParam.ts | 64 ++++++++++--------- 3 files changed, 65 insertions(+), 51 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index 95fadc9ed1..e0d417ddec 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -1,5 +1,11 @@ import * as Ariakit from "@ariakit/react"; -import { CalendarIcon, CpuChipIcon, Squares2X2Icon, TrashIcon } from "@heroicons/react/20/solid"; +import { + CalendarIcon, + CpuChipIcon, + Squares2X2Icon, + TrashIcon, + XMarkIcon, +} from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database"; import { ListFilterIcon } from "lucide-react"; @@ -37,6 +43,7 @@ import { descriptionForBatchStatus, } from "./BatchStatus"; import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters"; +import { StatusIcon } from "~/assets/icons/StatusIcon"; export const BatchStatus = z.enum(allBatchStatuses); @@ -71,9 +78,7 @@ export function BatchFilters(props: BatchFiltersProps) { {hasFilters && (
- +
@@ -107,9 +112,9 @@ function FilterMenu(props: BatchFiltersProps) {
} - variant={"minimal/small"} + variant={"secondary/small"} shortcut={shortcut} - tooltipTitle={"Filter runs"} + tooltipTitle={"Filter batches"} > Filter @@ -276,10 +281,12 @@ function AppliedStatusFilter() { }> } value={appliedSummary( statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus)) )} onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" /> } @@ -396,8 +403,10 @@ function AppliedBatchIdFilter() { }> } value={batchId} onRemove={() => del(["id", "cursor", "direction"])} + variant="secondary/small" /> } diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index 51a21c8bd5..14edfa3c7d 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -265,20 +265,23 @@ export function TimeDropdown({ setOpen(false); }, [fromValue, toValue, replace]); - const handlePeriodClick = useCallback((period: string) => { - replace({ - period, - cursor: undefined, - direction: undefined, - from: undefined, - to: undefined, - }); - - setFromValue(undefined); - setToValue(undefined); - - setOpen(false); - }, []); + const handlePeriodClick = useCallback( + (period: string) => { + replace({ + period, + cursor: undefined, + direction: undefined, + from: undefined, + to: undefined, + }); + + setFromValue(undefined); + setToValue(undefined); + + setOpen(false); + }, + [replace] + ); return ( diff --git a/apps/webapp/app/hooks/useSearchParam.ts b/apps/webapp/app/hooks/useSearchParam.ts index b8a49b20f4..3d0bb07e1b 100644 --- a/apps/webapp/app/hooks/useSearchParam.ts +++ b/apps/webapp/app/hooks/useSearchParam.ts @@ -7,42 +7,19 @@ type Values = Record; export function useSearchParams() { const navigate = useNavigate(); const location = useOptimisticLocation(); - const search = new URLSearchParams(location.search); - - const set = useCallback( - (values: Values) => { - for (const [param, value] of Object.entries(values)) { - if (value === undefined) { - search.delete(param); - continue; - } - - if (typeof value === "string") { - search.set(param, value); - continue; - } - - search.delete(param); - for (const v of value) { - search.append(param, v); - } - } - - return search; - }, - [location, search] - ); const replace = useCallback( (values: Values) => { - const s = set(values); + const s = set(new URLSearchParams(location.search), values); + navigate(`${location.pathname}?${s.toString()}`, { replace: true }); }, - [location, search, set] + [location, navigate] ); const del = useCallback( (keys: string | string[]) => { + const search = new URLSearchParams(location.search); if (!Array.isArray(keys)) { keys = [keys]; } @@ -51,11 +28,12 @@ export function useSearchParams() { } navigate(`${location.pathname}?${search.toString()}`, { replace: true }); }, - [location, search] + [location, navigate] ); const value = useCallback( (param: string) => { + const search = new URLSearchParams(location.search); const val = search.get(param) ?? undefined; if (val === undefined) { return val; @@ -63,22 +41,24 @@ export function useSearchParams() { return decodeURIComponent(val); }, - [location, search] + [location] ); const values = useCallback( (param: string) => { + const search = new URLSearchParams(location.search); const all = search.getAll(param); return all.map((v) => decodeURIComponent(v)); }, - [location, search] + [location] ); const has = useCallback( (param: string) => { + const search = new URLSearchParams(location.search); return search.has(param); }, - [location, search] + [location] ); return { @@ -89,3 +69,25 @@ export function useSearchParams() { has, }; } + +function set(searchParams: URLSearchParams, values: Values) { + const search = new URLSearchParams(searchParams); + for (const [param, value] of Object.entries(values)) { + if (value === undefined) { + search.delete(param); + continue; + } + + if (typeof value === "string") { + search.set(param, value); + continue; + } + + search.delete(param); + for (const v of value) { + search.append(param, v); + } + } + + return search; +} From f14cf0f8274ad5b2a91b7063c9d3c77c471e7889 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 17:00:23 +0100 Subject: [PATCH 053/212] Buggy filter fixes From 352cf0af5139c118580c954cad726d34c91183df Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 17:00:23 +0100 Subject: [PATCH 054/212] Buggy filter fixes From 73fc4eeb531efcb0c43189d997c9e128245bd156 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 18:49:15 +0100 Subject: [PATCH 055/212] Improved the filter display and fixed a bug with search param from object --- .../app/components/primitives/AppliedFilter.tsx | 8 ++++---- .../components/runs/v3/WaitpointTokenFilters.tsx | 16 +++++++++++----- ...vironments.$environmentId.runs.bulkaction.tsx | 12 ++++++------ apps/webapp/app/utils/searchParams.ts | 4 +++- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx index d03668e936..b1ba1cb81e 100644 --- a/apps/webapp/app/components/primitives/AppliedFilter.tsx +++ b/apps/webapp/app/components/primitives/AppliedFilter.tsx @@ -12,7 +12,7 @@ const variants = { clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors", }, "minimal/medium": { - box: "h-6 rounded gap-1.5 text-sm", + box: "rounded gap-1.5 text-sm", clear: "size-6 text-text-dimmed transition-colors", }, }; @@ -35,7 +35,7 @@ export function AppliedFilter({ value, removable = true, onRemove, - variant = "tertiary/small", + variant = "secondary/small", className, }: AppliedFilterProps) { const variantClassName = variants[variant]; @@ -48,8 +48,8 @@ export function AppliedFilter({ className )} > -
-
+
+
{icon}
{label}: diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index 7c64647628..bb4eef51af 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -75,9 +75,7 @@ export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) { {hasFilters && (
- +
@@ -109,7 +107,7 @@ function FilterMenu() {
} - variant={"minimal/small"} + variant={"secondary/small"} shortcut={shortcut} tooltipTitle={"Filter runs"} > @@ -285,10 +283,12 @@ function AppliedStatusFilter() { }> } value={appliedSummary( statuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus)) )} onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" /> } @@ -409,8 +409,10 @@ function AppliedTagsFilter() { }> } value={appliedSummary(values("tags"))} onRemove={() => del(["tags", "cursor", "direction"])} + variant="secondary/small" /> } @@ -527,8 +529,10 @@ function AppliedWaitpointIdFilter() { }> } value={id} onRemove={() => del(["id", "cursor", "direction"])} + variant="secondary/small" /> } @@ -594,7 +598,7 @@ function IdempotencyKeyDropdown({
setIdempotencyKey(e.target.value)} variant="small" @@ -643,8 +647,10 @@ function AppliedIdempotencyKeyFilter() { }> } value={idempotencyKey} onRemove={() => del(["idempotencyKey", "cursor", "direction"])} + variant="secondary/small" /> } 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 index 3989682c97..1c6e828eb0 100644 --- 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 @@ -296,7 +296,7 @@ function BulkActionPreview({ }); return ( -
+
You have selected{" "} @@ -304,7 +304,7 @@ function BulkActionPreview({ {" "} runs to be using these filters: -
+
+ Canceled - + ); case "replay": return ( - <> + Replayed - + ); } } diff --git a/apps/webapp/app/utils/searchParams.ts b/apps/webapp/app/utils/searchParams.ts index a3885ab5bc..4e3b0682b0 100644 --- a/apps/webapp/app/utils/searchParams.ts +++ b/apps/webapp/app/utils/searchParams.ts @@ -13,7 +13,9 @@ export function objectToSearchParams( Object.entries(obj).forEach(([key, value]) => { if (value === undefined) return; if (Array.isArray(value)) { - searchParams.append(key, value.join(",")); + for (const v of value) { + searchParams.append(key, v.toString()); + } } else { searchParams.append(key, value.toString()); } From 19ab6c712af04531efd2ced59d71689e46aa5cfc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 18:49:15 +0100 Subject: [PATCH 056/212] Improved the filter display and fixed a bug with search param from object From 3a127f67973b5f022ffeee45aa0f0eb34523a039 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 18:49:15 +0100 Subject: [PATCH 057/212] Improved the filter display and fixed a bug with search param from object From 4f6adcb5fbc538e478573ed4c0b8c804b4fba882 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 18:53:45 +0100 Subject: [PATCH 058/212] Clear button is minimal --- apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index bb4eef51af..95f91a34db 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -75,7 +75,7 @@ export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) { {hasFilters && (
-
From 5e2bdf23ce6e77c0aa25b32fce6390fac87b4a69 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 18:53:45 +0100 Subject: [PATCH 059/212] Clear button is minimal From 1426ad1ab227c3e72774809dfd1e4cd3e8ce6d4e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 2 Jul 2025 18:53:45 +0100 Subject: [PATCH 060/212] Clear button is minimal From be5585b2596d11c5be1461d427e227d9916f8965 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Jul 2025 13:23:24 +0100 Subject: [PATCH 061/212] Using a presenter now --- .../v3/CreateBulkActionPresenter.server.ts | 50 +++++++ .../route.tsx | 2 +- ...ctParam.env.$envParam.runs.bulkaction.tsx} | 128 ++++++++++++------ 3 files changed, 135 insertions(+), 45 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts rename apps/webapp/app/routes/{resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction.tsx => resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx} (84%) diff --git a/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts b/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts new file mode 100644 index 0000000000..9060922dd0 --- /dev/null +++ b/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts @@ -0,0 +1,50 @@ +import { type PrismaClient } from "@trigger.dev/database"; +import { CreateBulkActionSearchParams } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { RunsRepository } from "~/services/runsRepository.server"; +import { getRunFiltersFromRequest } from "../RunFilters.server"; +import { BasePresenter } from "./basePresenter.server"; + +type CreateBulkActionOptions = { + organizationId: string; + projectId: string; + environmentId: string; + request: Request; +}; + +export class CreateBulkActionPresenter extends BasePresenter { + public async call({ + organizationId, + projectId, + environmentId, + request, + }: CreateBulkActionOptions) { + const filters = await getRunFiltersFromRequest(request); + const { mode, action } = CreateBulkActionSearchParams.parse( + Object.fromEntries(new URL(request.url).searchParams) + ); + + if (!clickhouseClient) { + throw new Error("Clickhouse client not found"); + } + + const runsRepository = new RunsRepository({ + clickhouse: clickhouseClient, + prisma: this._replica as PrismaClient, + }); + + const count = await runsRepository.countRuns({ + organizationId, + projectId, + environmentId, + ...filters, + }); + + return { + filters, + mode, + action, + count, + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 9dd775de1e..c1c5144964 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -66,7 +66,7 @@ import { v3TestPath, } from "~/utils/pathBuilder"; import { ListPagination } from "../../components/ListPagination"; -import { CreateBulkActionInspector } from "../resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction"; +import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction"; export const meta: MetaFunction = () => { return [ diff --git a/apps/webapp/app/routes/resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx similarity index 84% rename from apps/webapp/app/routes/resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction.tsx rename to apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 1c6e828eb0..fdff5c5d83 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationId.projects.$projectId.environments.$environmentId.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -4,7 +4,7 @@ import { Form } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; import { type TaskRunStatus } from "@trigger.dev/database"; import assertNever from "assert-never"; -import { filter } from "compression"; +import { parse } from "@conform-to/zod"; import { useEffect } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; import simplur from "simplur"; @@ -39,69 +39,110 @@ 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 { redirectWithErrorMessage, 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 { formatNumber } from "~/utils/numberFormatter"; -import { v3RunsPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3CreateBulkActionPath, v3RunsPath } from "~/utils/pathBuilder"; +import { logger } from "~/services/logger.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server"; -const Params = z.object({ - organizationId: z.string(), - projectId: z.string(), - environmentId: z.string(), -}); +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const presenter = new CreateBulkActionPresenter(); + const data = await presenter.call({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + request, + }); + + return typedjson(data); +} const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]); type BulkActionMode = z.infer; const BulkActionAction = z.union([z.literal("cancel"), z.literal("replay")]); type BulkActionAction = z.infer; -const searchParams = z.object({ +export const CreateBulkActionSearchParams = z.object({ mode: BulkActionMode.default("filter"), action: BulkActionAction.default("cancel"), }); -export async function loader({ request, params }: LoaderFunctionArgs) { +const FormSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("selected"), + action: BulkActionAction, + selectedRunIds: z.array(z.string()), + title: z.string().optional(), + }), + z.object({ + mode: z.literal("filter"), + action: BulkActionAction, + title: z.string().optional(), + }), +]); + +export async function action({ params, request }: ActionFunctionArgs) { + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + 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 permission check - //todo do a ClickHouse Query with the filters - if (!clickhouseClient) { - throw new Error("Clickhouse client not found"); - } + const formData = await request.formData(); + const submission = parse(formData, { schema: FormSchema }); - const runsRepository = new RunsRepository({ - clickhouse: clickhouseClient, - prisma: $replica as PrismaClient, - }); + if (!submission.value) { + logger.error("Invalid bulk action", { + submission, + formData: Object.fromEntries(formData), + }); + return redirectWithErrorMessage("/", request, "Invalid bulk action"); + } - const count = await runsRepository.countRuns({ - organizationId, - projectId, - environmentId, - ...filters, - }); + switch (submission.value.mode) { + case "selected": { + const { action, selectedRunIds, title } = submission.value; - return typedjson({ - filters, - mode, - action, - count, - }); -} + logger.log("Selected runs", { + action, + selectedRunIds, + title, + }); + break; + } + case "filter": { + const filters = await getRunFiltersFromRequest(request); -export async function action({ params, request }: ActionFunctionArgs) { - const { organizationId, projectId, environmentId } = Params.parse(params); - const filters = await getRunFiltersFromRequest(request); + logger.log("Filter runs", { + action, + filters, + }); + break; + } + } + //todo go to the bulk action page return redirectWithSuccessMessage("/", request, "SORTED"); } @@ -121,7 +162,7 @@ export function CreateBulkActionInspector({ useEffect(() => { fetcher.load( - `/resources/orgs/${organization.id}/projects/${project.id}/environments/${environment.id}/runs/bulkaction${location.search}` + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/bulkaction${location.search}` ); }, [organization.id, project.id, environment.id, location.search]); @@ -139,7 +180,7 @@ export function CreateBulkActionInspector({ return (
@@ -191,10 +232,9 @@ export function CreateBulkActionInspector({ - - + + Add a name to identify this bulk action (optional). - {/* todo {name.error} */} From 39e004e45aafa980be2247d24d43618009554ed0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Jul 2025 13:23:24 +0100 Subject: [PATCH 062/212] Using a presenter now From a7b41e56b7d88897b1c60c38bdf08a13d557e743 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Jul 2025 13:23:24 +0100 Subject: [PATCH 063/212] Using a presenter now From 020e9f423b16dce8e25d086d780c417b04fdc5d6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 12:09:36 +0100 Subject: [PATCH 064/212] Bulk actions are created, but not actually processed (yet) --- apps/webapp/app/env.server.ts | 7 +- ...ectParam.env.$envParam.runs.bulkaction.tsx | 61 ++++--- .../app/services/runsRepository.server.ts | 75 ++++----- apps/webapp/app/v3/commonWorker.server.ts | 14 ++ .../app/v3/services/baseService.server.ts | 7 +- .../bulk/createBulkActionV2.server.ts | 154 ++++++++++++++++++ internal-packages/clickhouse/src/index.ts | 1 + packages/core/src/v3/isomorphic/friendlyId.ts | 1 + 8 files changed, 250 insertions(+), 70 deletions(-) create mode 100644 apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 7934bf1a5e..004a2aeb5e 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1,8 +1,7 @@ import { z } from "zod"; +import { BoolEnv } from "./utils/boolEnv"; import { isValidDatabaseUrl } from "./utils/db"; import { isValidRegex } from "./utils/regex"; -import { BoolEnv } from "./utils/boolEnv"; -import { OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT, OTEL_LINK_COUNT_LIMIT } from "@trigger.dev/core/v3"; const EnvironmentSchema = z.object({ NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]), @@ -970,6 +969,10 @@ const EnvironmentSchema = z.object({ .number() .int() .default(60_000 * 60 * 24), + + // Bulk action + BULK_ACTION_BATCH_SIZE: z.coerce.number().int().default(100), + BULK_ACTION_BATCH_DELAY_MS: z.coerce.number().int().default(200), }); export type Environment = z.infer; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index fdff5c5d83..60f309bfdd 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -51,6 +51,8 @@ import { logger } from "~/services/logger.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { findProjectBySlug } from "~/models/project.server"; import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server"; +import { BulkActionService } from "~/v3/services/bulk/createBulkActionV2.server"; +import { tryCatch } from "@trigger.dev/core"; export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request); @@ -88,7 +90,7 @@ export const CreateBulkActionSearchParams = z.object({ action: BulkActionAction.default("cancel"), }); -const FormSchema = z.discriminatedUnion("mode", [ +export const CreateBulkActionPayload = z.discriminatedUnion("mode", [ z.object({ mode: z.literal("selected"), action: BulkActionAction, @@ -101,16 +103,27 @@ const FormSchema = z.discriminatedUnion("mode", [ title: z.string().optional(), }), ]); +export type CreateBulkActionPayload = z.infer; export async function action({ params, request }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - const userId = await requireUserId(request); + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } - //todo permission check + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } const formData = await request.formData(); - const submission = parse(formData, { schema: FormSchema }); + const submission = parse(formData, { schema: CreateBulkActionPayload }); + + //todo add success and failure paths in the form if (!submission.value) { logger.error("Invalid bulk action", { @@ -120,30 +133,28 @@ export async function action({ params, request }: ActionFunctionArgs) { return redirectWithErrorMessage("/", request, "Invalid bulk action"); } - switch (submission.value.mode) { - case "selected": { - const { action, selectedRunIds, title } = submission.value; - - logger.log("Selected runs", { - action, - selectedRunIds, - title, - }); - break; - } - case "filter": { - const filters = await getRunFiltersFromRequest(request); + const service = new BulkActionService(); + const [error, result] = await tryCatch( + service.create( + project.organizationId, + project.id, + environment.id, + userId, + submission.value, + request + ) + ); - logger.log("Filter runs", { - action, - filters, - }); - break; - } + if (error) { + logger.error("Failed to create bulk action", { + error, + }); + // todo decent error message + return redirectWithErrorMessage("/", request, "Failed to create bulk action"); } - //todo go to the bulk action page - return redirectWithSuccessMessage("/", request, "SORTED"); + //todo redirect to the bulk action page + return redirectWithSuccessMessage("/", request, "Bulk action created"); } export function CreateBulkActionInspector({ diff --git a/apps/webapp/app/services/runsRepository.server.ts b/apps/webapp/app/services/runsRepository.server.ts index 08f3d01731..0aeb51f70c 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository.server.ts @@ -1,11 +1,11 @@ -import { type ClickHouse } from "@internal/clickhouse"; -import { type ClickhouseQueryBuilder } from "@internal/clickhouse/dist/src/client/queryBuilder"; +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 { type TaskRunStatus } from "@trigger.dev/database"; +import { Prisma, TaskRunStatus } from "@trigger.dev/database"; import parseDuration from "parse-duration"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { type PrismaClient } from "~/db.server"; +import { z } from "zod"; export type RunsRepositoryOptions = { clickhouse: ClickHouse; @@ -15,43 +15,32 @@ export type RunsRepositoryOptions = { tracer?: Tracer; }; -type RunListInputOptions = { - organizationId: string; - projectId: string; - environmentId: string; - //filters - tasks?: string[]; - versions?: string[]; - statuses?: TaskRunStatus[]; - tags?: string[]; - scheduleId?: string; - period?: string; - bulkId?: string; - from?: number; - to?: number; - isTest?: boolean; - rootOnly?: boolean; - batchId?: string; - runIds?: string[]; -}; +const RunStatus = z.enum(Object.values(TaskRunStatus) as [TaskRunStatus, ...TaskRunStatus[]]); -type FilterRunsOptions = { - organizationId: string; - projectId: string; - environmentId: string; +const RunListInputOptionsSchema = z.object({ + organizationId: z.string(), + projectId: z.string(), + environmentId: z.string(), //filters - tasks?: string[]; - versions?: string[]; - statuses?: TaskRunStatus[]; - tags?: string[]; - scheduleId?: string; - period?: number; - from?: number; - to?: number; - isTest?: boolean; - rootOnly?: boolean; - batchId?: string; - runFriendlyIds?: string[]; + tasks: z.array(z.string()).optional(), + versions: z.array(z.string()).optional(), + statuses: z.array(RunStatus).optional(), + tags: z.array(z.string()).optional(), + scheduleId: z.string().optional(), + period: z.string().optional(), + from: z.number().optional(), + to: z.number().optional(), + isTest: z.boolean().optional(), + rootOnly: z.boolean().optional(), + batchId: z.string().optional(), + runIds: z.array(z.string()).optional(), + bulkId: z.string().optional(), +}); + +export type RunListInputOptions = z.infer; + +type FilterRunsOptions = Omit & { + period: number | undefined; }; type Pagination = { @@ -334,9 +323,13 @@ function applyRunFiltersToQueryBuilder( // TODO new bulk action filtering - 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("friendly_id IN {runIds: Array(String)}", { + runIds: options.runIds, }); } } + +export function parseRunListInputOptions(data: any): RunListInputOptions { + return RunListInputOptionsSchema.parse(data); +} diff --git a/apps/webapp/app/v3/commonWorker.server.ts b/apps/webapp/app/v3/commonWorker.server.ts index 7669c1aed7..dc256a9666 100644 --- a/apps/webapp/app/v3/commonWorker.server.ts +++ b/apps/webapp/app/v3/commonWorker.server.ts @@ -20,6 +20,7 @@ import { ResumeBatchRunService } from "./services/resumeBatchRun.server"; import { ResumeTaskDependencyService } from "./services/resumeTaskDependency.server"; import { RetryAttemptService } from "./services/retryAttempt.server"; import { TimeoutDeploymentService } from "./services/timeoutDeployment.server"; +import { BulkActionService } from "./services/bulk/createBulkActionV2.server"; function initializeWorker() { const redisOptions = { @@ -189,6 +190,15 @@ function initializeWorker() { maxAttempts: 6, }, }, + processBulkAction: { + schema: z.object({ + bulkActionId: z.string(), + }), + visibilityTimeoutMs: 180_000, + retry: { + maxAttempts: 5, + }, + }, }, concurrency: { workers: env.COMMON_WORKER_CONCURRENCY_WORKERS, @@ -268,6 +278,10 @@ function initializeWorker() { await service.call(payload.runId); }, + processBulkAction: async ({ payload }) => { + const service = new BulkActionService(); + await service.process(payload.bulkActionId); + }, }, }); diff --git a/apps/webapp/app/v3/services/baseService.server.ts b/apps/webapp/app/v3/services/baseService.server.ts index 7686f41b6f..58ca39aaf0 100644 --- a/apps/webapp/app/v3/services/baseService.server.ts +++ b/apps/webapp/app/v3/services/baseService.server.ts @@ -1,11 +1,14 @@ import { Span, SpanKind } from "@opentelemetry/api"; -import { PrismaClientOrTransaction, prisma } from "~/db.server"; +import { $replica, PrismaClientOrTransaction, prisma } from "~/db.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { attributesFromAuthenticatedEnv, tracer } from "../tracer.server"; import { engine, RunEngine } from "../runEngine.server"; export abstract class BaseService { - constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {} + constructor( + protected readonly _prisma: PrismaClientOrTransaction = prisma, + protected readonly _replica: PrismaClientOrTransaction = $replica + ) {} protected async traceWithEnv( trace: string, diff --git a/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts new file mode 100644 index 0000000000..660ee9ac30 --- /dev/null +++ b/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts @@ -0,0 +1,154 @@ +import { BulkActionId } from "@trigger.dev/core/v3/isomorphic"; +import { BulkActionType, type PrismaClient } from "@trigger.dev/database"; +import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; +import { type CreateBulkActionPayload } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { parseRunListInputOptions, RunsRepository } from "~/services/runsRepository.server"; +import { BaseService } from "../baseService.server"; +import { commonWorker } from "~/v3/commonWorker.server"; +import { env } from "~/env.server"; + +export class BulkActionService extends BaseService { + public async create( + organizationId: string, + projectId: string, + environmentId: string, + userId: string, + payload: CreateBulkActionPayload, + request: Request + ) { + const filters = await getFilters(payload, request); + + if (!clickhouseClient) { + throw new Error("Clickhouse client not found"); + } + + // Count the runs that will be affected by the bulk action + const runsRepository = new RunsRepository({ + clickhouse: clickhouseClient, + prisma: this._replica as PrismaClient, + }); + const count = await runsRepository.countRuns({ + organizationId, + projectId, + environmentId, + ...filters, + }); + + // Create the bulk action group + const { id, friendlyId } = BulkActionId.generate(); + const group = await this._prisma.bulkActionGroup.create({ + data: { + id, + friendlyId, + projectId, + environmentId, + userId, + name: payload.title, + type: payload.action === "cancel" ? BulkActionType.CANCEL : BulkActionType.REPLAY, + params: filters, + queryName: "bulk_action_v1", + totalCount: count, + }, + }); + + // Queue the bulk action group for immediate processing + await commonWorker.enqueue({ + id: `processBulkAction-${group.id}`, + job: "processBulkAction", + payload: { + bulkActionId: group.id, + }, + }); + + return { + bulkActionId: group.friendlyId, + }; + } + + public async process(bulkActionId: string) { + // 1. Get the bulk action group + const group = await this._prisma.bulkActionGroup.findUnique({ + where: { id: bulkActionId }, + select: { + projectId: true, + environmentId: true, + project: { + select: { + organizationId: true, + }, + }, + type: true, + queryName: true, + params: true, + cursor: true, + }, + }); + + if (!group) { + throw new Error(`Bulk action group not found: ${bulkActionId}`); + } + + if (!group.environmentId) { + throw new Error(`Bulk action group has no environment: ${bulkActionId}`); + } + + // 2. Parse the params + const filters = parseRunListInputOptions({ + organizationId: group.project.organizationId, + projectId: group.projectId, + environmentId: group.environmentId, + ...(group.params && typeof group.params === "object" ? group.params : {}), + }); + + if (!clickhouseClient) { + throw new Error("Clickhouse client not found"); + } + + // Count the runs that will be affected by the bulk action + const runsRepository = new RunsRepository({ + clickhouse: clickhouseClient, + prisma: this._replica as PrismaClient, + }); + + // In the future we can support multiple query names, when we make changes + if (group.queryName !== "bulk_action_v1") { + throw new Error(`Bulk action group has invalid query name: ${group.queryName}`); + } + + // 2. Get the runs to process in this batch + const runs = await runsRepository.listRunIds({ + ...filters, + page: { + size: env.BULK_ACTION_BATCH_SIZE, + cursor: + typeof group.cursor === "string" && group.cursor !== null ? group.cursor : undefined, + }, + }); + + // 3. Process the runs + + // 4. Update the bulk action group + + // 5. If there are more runs to process, queue the next batch + } +} + +async function getFilters(payload: CreateBulkActionPayload, request: Request) { + if (payload.mode === "selected") { + return { + runIds: payload.selectedRunIds, + }; + } + + const filters = await getRunFiltersFromRequest(request); + filters.cursor = undefined; + + // If there isn't a time period or to date, we set the to date to now + // Otherwise this could run forever if lots of new runs are being created + if (!filters.period && !filters.to) { + filters.to = Date.now(); + } + + return filters; +} diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index 44ec2ec60b..599492eb53 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -17,6 +17,7 @@ import type { Agent as HttpAgent } from "http"; import type { Agent as HttpsAgent } from "https"; export type * from "./taskRuns.js"; +export type * from "./client/queryBuilder.js"; export type ClickhouseCommonConfig = { keepAlive?: { diff --git a/packages/core/src/v3/isomorphic/friendlyId.ts b/packages/core/src/v3/isomorphic/friendlyId.ts index d87eb4d212..7b4f8a7f3d 100644 --- a/packages/core/src/v3/isomorphic/friendlyId.ts +++ b/packages/core/src/v3/isomorphic/friendlyId.ts @@ -94,6 +94,7 @@ export const RunId = new IdUtil("run"); export const SnapshotId = new IdUtil("snapshot"); export const WaitpointId = new IdUtil("waitpoint"); export const BatchId = new IdUtil("batch"); +export const BulkActionId = new IdUtil("bulk"); export class IdGenerator { private alphabet: string; From 3b8e95fcf4afce2334e807489cdc8ac834040fa8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 12:09:36 +0100 Subject: [PATCH 065/212] Bulk actions are created, but not actually processed (yet) From 53db05370272e14a7d9a2fb5d12ae2175d8c0658 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 12:09:36 +0100 Subject: [PATCH 066/212] Bulk actions are created, but not actually processed (yet) From aaab539c39d6b448a0d38178242fb5d52d3edddd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 13:53:17 +0100 Subject: [PATCH 067/212] Bulk replay/cancel is working --- .../bulk/createBulkActionV2.server.ts | 131 +++++++++++++++++- .../app/v3/services/cancelTaskRun.server.ts | 12 +- .../app/v3/services/cancelTaskRunV1.server.ts | 3 +- .../app/v3/services/replayTaskRun.server.ts | 1 + 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts index 660ee9ac30..178ed7a59c 100644 --- a/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts +++ b/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts @@ -1,5 +1,5 @@ import { BulkActionId } from "@trigger.dev/core/v3/isomorphic"; -import { BulkActionType, type PrismaClient } from "@trigger.dev/database"; +import { BulkActionStatus, BulkActionType, type PrismaClient } from "@trigger.dev/database"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; import { type CreateBulkActionPayload } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; @@ -7,6 +7,10 @@ import { parseRunListInputOptions, RunsRepository } from "~/services/runsReposit import { BaseService } from "../baseService.server"; import { commonWorker } from "~/v3/commonWorker.server"; import { env } from "~/env.server"; +import { logger } from "@trigger.dev/sdk"; +import { CancelTaskRunService } from "../cancelTaskRun.server"; +import { tryCatch } from "@trigger.dev/core"; +import { ReplayTaskRunService } from "../replayTaskRun.server"; export class BulkActionService extends BaseService { public async create( @@ -71,6 +75,7 @@ export class BulkActionService extends BaseService { const group = await this._prisma.bulkActionGroup.findUnique({ where: { id: bulkActionId }, select: { + friendlyId: true, projectId: true, environmentId: true, project: { @@ -117,7 +122,7 @@ export class BulkActionService extends BaseService { } // 2. Get the runs to process in this batch - const runs = await runsRepository.listRunIds({ + const runIds = await runsRepository.listRunIds({ ...filters, page: { size: env.BULK_ACTION_BATCH_SIZE, @@ -127,10 +132,132 @@ export class BulkActionService extends BaseService { }); // 3. Process the runs + let successCount = 0; + let failureCount = 0; + // Slice because we fetch an extra for the cursor + const runIdsToProcess = runIds.slice(0, env.BULK_ACTION_BATCH_SIZE); + + switch (group.type) { + case BulkActionType.CANCEL: { + const cancelService = new CancelTaskRunService(this._prisma); + + const runs = await this._replica.taskRun.findMany({ + where: { + id: { + in: runIdsToProcess, + }, + }, + select: { + id: true, + engine: true, + friendlyId: true, + status: true, + createdAt: true, + completedAt: true, + taskEventStore: true, + }, + }); + + for (const run of runs) { + const [error, result] = await tryCatch( + cancelService.call(run, { + reason: `Bulk action ${group.friendlyId} cancelled run`, + bulkActionId: bulkActionId, + }) + ); + if (error) { + logger.error("Failed to cancel run", { + error, + runId: run.id, + status: run.status, + }); + + failureCount++; + } else { + successCount++; + } + } + } + case BulkActionType.REPLAY: { + const replayService = new ReplayTaskRunService(this._prisma); + + const runs = await this._replica.taskRun.findMany({ + where: { + id: { + in: runIdsToProcess, + }, + }, + }); + + for (const run of runs) { + const [error, result] = await tryCatch( + replayService.call(run, { + bulkActionId: bulkActionId, + }) + ); + if (error) { + logger.error("Failed to replay run, error", { + error, + runId: run.id, + status: run.status, + }); + + failureCount++; + } else { + if (!result) { + logger.error("Failed to replay run, no result", { + runId: run.id, + status: run.status, + }); + + failureCount++; + } else { + successCount++; + } + } + } + } + } + + const isFinished = runIdsToProcess.length < env.BULK_ACTION_BATCH_SIZE; + + logger.log("Bulk action group processed batch", { + bulkActionId, + organizationId: group.project.organizationId, + projectId: group.projectId, + environmentId: group.environmentId, + batchSize: runIdsToProcess.length, + cursor: group.cursor, + successCount, + failureCount, + isFinished, + }); // 4. Update the bulk action group + await this._prisma.bulkActionGroup.update({ + where: { id: bulkActionId }, + data: { + cursor: runIdsToProcess[runIdsToProcess.length - 1], + successCount: { + increment: successCount, + }, + failureCount: { + increment: failureCount, + }, + status: isFinished ? BulkActionStatus.COMPLETED : undefined, + completedAt: isFinished ? new Date() : undefined, + }, + }); // 5. If there are more runs to process, queue the next batch + if (!isFinished) { + await commonWorker.enqueue({ + id: `processBulkAction-${bulkActionId}`, + job: "processBulkAction", + payload: { bulkActionId }, + availableAt: new Date(Date.now() + env.BULK_ACTION_BATCH_DELAY_MS), + }); + } } } diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index d664d754e8..0dd61b8946 100644 --- a/apps/webapp/app/v3/services/cancelTaskRun.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRun.server.ts @@ -10,15 +10,21 @@ export type CancelTaskRunServiceOptions = { reason?: string; cancelAttempts?: boolean; cancelledAt?: Date; + bulkActionId?: string; }; type CancelTaskRunServiceResult = { id: string; }; +export type CancelableTaskRun = Pick< + TaskRun, + "id" | "engine" | "status" | "friendlyId" | "taskEventStore" | "createdAt" | "completedAt" +>; + export class CancelTaskRunService extends BaseService { public async call( - taskRun: TaskRun, + taskRun: CancelableTaskRun, options?: CancelTaskRunServiceOptions ): Promise { if (taskRun.engine === RunEngineVersion.V1) { @@ -29,7 +35,7 @@ export class CancelTaskRunService extends BaseService { } private async callV1( - taskRun: TaskRun, + taskRun: CancelableTaskRun, options?: CancelTaskRunServiceOptions ): Promise { const service = new CancelTaskRunServiceV1(this._prisma); @@ -37,7 +43,7 @@ export class CancelTaskRunService extends BaseService { } private async callV2( - taskRun: TaskRun, + taskRun: CancelableTaskRun, options?: CancelTaskRunServiceOptions ): Promise { const result = await engine.cancelRun({ diff --git a/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts b/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts index 5d9d621a26..d26a4c10bd 100644 --- a/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts @@ -10,6 +10,7 @@ import { CancelAttemptService } from "./cancelAttempt.server"; import { CancelTaskAttemptDependenciesService } from "./cancelTaskAttemptDependencies.server"; import { FinalizeTaskRunService } from "./finalizeTaskRun.server"; import { getTaskEventStoreTableForRun } from "../taskEventStore.server"; +import { CancelableTaskRun } from "./cancelTaskRun.server"; type ExtendedTaskRun = Prisma.TaskRunGetPayload<{ include: { @@ -31,7 +32,7 @@ export type CancelTaskRunServiceOptions = { }; export class CancelTaskRunServiceV1 extends BaseService { - public async call(taskRun: TaskRun, options?: CancelTaskRunServiceOptions) { + public async call(taskRun: CancelableTaskRun, options?: CancelTaskRunServiceOptions) { const opts = { reason: "Task run was cancelled by user", cancelAttempts: true, diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index 2c45fa2b03..62cd88700f 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -15,6 +15,7 @@ type OverrideOptions = { environmentId?: string; payload?: unknown; metadata?: unknown; + bulkActionId?: string; } & RunOptionsData; export class ReplayTaskRunService extends BaseService { From 932de42a2d9d76e2a375e4576063334686722520 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 13:53:17 +0100 Subject: [PATCH 068/212] Bulk replay/cancel is working From 678e9eefc835f83554630e0d43db9b295a9784d6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 13:53:17 +0100 Subject: [PATCH 069/212] Bulk replay/cancel is working From 72c131bf55705ead9556bb67ea87dbc3962b183e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:11:40 +0100 Subject: [PATCH 070/212] Multiple fixes, added bulk column to PG --- .../app/services/runsRepository.server.ts | 2 +- .../services/bulk/createBulkActionV2.server.ts | 9 +++++++-- .../app/v3/services/cancelTaskRun.server.ts | 2 ++ .../app/v3/services/cancelTaskRunV1.server.ts | 15 +++++++++++++++ .../app/v3/services/finalizeTaskRun.server.ts | 14 +++++++++++++- .../app/v3/services/replayTaskRun.server.ts | 1 + .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 2 ++ .../run-engine/src/engine/index.ts | 3 +++ .../src/engine/systems/runAttemptSystem.ts | 17 +++++++++++++++++ 10 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250708130212_task_run_add_bulk_action_group_ids/migration.sql diff --git a/apps/webapp/app/services/runsRepository.server.ts b/apps/webapp/app/services/runsRepository.server.ts index 0aeb51f70c..901ae89753 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository.server.ts @@ -64,7 +64,7 @@ export class RunsRepository { ); if (options.page.cursor) { - if (options.page.direction === "forward") { + if (options.page.direction === "forward" || !options.page.direction) { queryBuilder .where("run_id < {runId: String}", { runId: options.page.cursor }) .orderBy("created_at DESC, run_id DESC") diff --git a/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts index 178ed7a59c..fe87523356 100644 --- a/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts +++ b/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts @@ -110,7 +110,6 @@ export class BulkActionService extends BaseService { throw new Error("Clickhouse client not found"); } - // Count the runs that will be affected by the bulk action const runsRepository = new RunsRepository({ clickhouse: clickhouseClient, prisma: this._replica as PrismaClient, @@ -177,6 +176,8 @@ export class BulkActionService extends BaseService { successCount++; } } + + break; } case BulkActionType.REPLAY: { const replayService = new ReplayTaskRunService(this._prisma); @@ -216,10 +217,11 @@ export class BulkActionService extends BaseService { } } } + break; } } - const isFinished = runIdsToProcess.length < env.BULK_ACTION_BATCH_SIZE; + const isFinished = runIdsToProcess.length === 0; logger.log("Bulk action group processed batch", { bulkActionId, @@ -265,11 +267,14 @@ async function getFilters(payload: CreateBulkActionPayload, request: Request) { if (payload.mode === "selected") { return { runIds: payload.selectedRunIds, + cursor: undefined, + direction: undefined, }; } const filters = await getRunFiltersFromRequest(request); filters.cursor = undefined; + filters.direction = undefined; // If there isn't a time period or to date, we set the to date to now // Otherwise this could run forever if lots of new runs are being created diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index 0dd61b8946..a7cfbf8027 100644 --- a/apps/webapp/app/v3/services/cancelTaskRun.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRun.server.ts @@ -46,10 +46,12 @@ export class CancelTaskRunService extends BaseService { taskRun: CancelableTaskRun, options?: CancelTaskRunServiceOptions ): Promise { + //todo bulkActionId const result = await engine.cancelRun({ runId: taskRun.id, completedAt: options?.cancelledAt, reason: options?.reason, + bulkActionId: options?.bulkActionId, tx: this._prisma, }); diff --git a/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts b/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts index d26a4c10bd..fa30d7fc7b 100644 --- a/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts @@ -29,6 +29,7 @@ export type CancelTaskRunServiceOptions = { reason?: string; cancelAttempts?: boolean; cancelledAt?: Date; + bulkActionId?: string; }; export class CancelTaskRunServiceV1 extends BaseService { @@ -46,6 +47,19 @@ export class CancelTaskRunServiceV1 extends BaseService { runId: taskRun.id, status: taskRun.status, }); + + //add the bulk action id to the run + if (opts.bulkActionId) { + await this._prisma.taskRun.update({ + where: { id: taskRun.id }, + data: { + bulkActionGroupIds: { + push: opts.bulkActionId, + }, + }, + }); + } + return; } @@ -54,6 +68,7 @@ export class CancelTaskRunServiceV1 extends BaseService { id: taskRun.id, status: "CANCELED", completedAt: opts.cancelledAt, + bulkActionId: opts.bulkActionId, include: { attempts: { where: { diff --git a/apps/webapp/app/v3/services/finalizeTaskRun.server.ts b/apps/webapp/app/v3/services/finalizeTaskRun.server.ts index 2316fd25f4..e5e448fc90 100644 --- a/apps/webapp/app/v3/services/finalizeTaskRun.server.ts +++ b/apps/webapp/app/v3/services/finalizeTaskRun.server.ts @@ -29,6 +29,7 @@ type BaseInput = { error?: TaskRunError; metadata?: FlushedRunMetadata; env?: AuthenticatedEnvironment; + bulkActionId?: string; }; type InputWithInclude = BaseInput & { @@ -49,6 +50,7 @@ export class FinalizeTaskRunService extends BaseService { status, expiredAt, completedAt, + bulkActionId, include, attemptStatus, error, @@ -98,7 +100,17 @@ export class FinalizeTaskRunService extends BaseService { const run = await this._prisma.taskRun.update({ where: { id }, - data: { status, expiredAt, completedAt, error: taskRunError }, + data: { + status, + expiredAt, + completedAt, + error: taskRunError, + bulkActionGroupIds: bulkActionId + ? { + push: bulkActionId, + } + : undefined, + }, ...(include ? { include } : {}), }); diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index 62cd88700f..61da68723a 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -81,6 +81,7 @@ export class ReplayTaskRunService extends BaseService { undefined, lockToVersion: overrideOptions.version === "latest" ? undefined : overrideOptions.version, + //todo: add bulkActionId to the replay }, }, { diff --git a/internal-packages/database/prisma/migrations/20250708130212_task_run_add_bulk_action_group_ids/migration.sql b/internal-packages/database/prisma/migrations/20250708130212_task_run_add_bulk_action_group_ids/migration.sql new file mode 100644 index 0000000000..f8b0c70568 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250708130212_task_run_add_bulk_action_group_ids/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TaskRun" ADD COLUMN "bulkActionGroupIds" TEXT[] DEFAULT ARRAY[]::TEXT[]; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 507323c07e..95f48efe62 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -644,6 +644,8 @@ model TaskRun { sourceBulkActionItems BulkActionItem[] @relation("SourceActionItemRun") destinationBulkActionItems BulkActionItem[] @relation("DestinationActionItemRun") + bulkActionGroupIds String[] @default([]) + logsDeletedAt DateTime? /// This represents the original task that that was triggered outside of a Trigger.dev task diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 8ed9febb31..ea8b2f0f65 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -701,6 +701,7 @@ export class RunEngine { completedAt, reason, finalizeRun, + bulkActionId, tx, }: { runId: string; @@ -709,6 +710,7 @@ export class RunEngine { completedAt?: Date; reason?: string; finalizeRun?: boolean; + bulkActionId?: string; tx?: PrismaClientOrTransaction; }): Promise { return this.runAttemptSystem.cancelRun({ @@ -718,6 +720,7 @@ export class RunEngine { completedAt, reason, finalizeRun, + bulkActionId, tx, }); } diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 11a42121e1..13f1d2cdbc 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -962,6 +962,7 @@ export class RunAttemptSystem { completedAt, reason, finalizeRun, + bulkActionId, tx, }: { runId: string; @@ -970,6 +971,7 @@ export class RunAttemptSystem { completedAt?: Date; reason?: string; finalizeRun?: boolean; + bulkActionId?: string; tx?: PrismaClientOrTransaction; }): Promise { const prisma = tx ?? this.$.prisma; @@ -981,6 +983,16 @@ export class RunAttemptSystem { //already finished, do nothing if (latestSnapshot.executionStatus === "FINISHED") { + if (bulkActionId) { + await prisma.taskRun.update({ + where: { id: runId }, + data: { + bulkActionGroupIds: { + push: bulkActionId, + }, + }, + }); + } return executionResultFromSnapshot(latestSnapshot); } @@ -1006,6 +1018,11 @@ export class RunAttemptSystem { status: "CANCELED", completedAt: finalizeRun ? completedAt ?? new Date() : completedAt, error, + bulkActionGroupIds: bulkActionId + ? { + push: bulkActionId, + } + : undefined, }, select: { id: true, From a1f48c9e2242fbbfac316179a580cced31b8c8d6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:11:40 +0100 Subject: [PATCH 071/212] Multiple fixes, added bulk column to PG From 4022627ec7969994caddfbee627bf7a9abb61deb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:11:40 +0100 Subject: [PATCH 072/212] Multiple fixes, added bulk column to PG From 3268e0a613b0d219a3ca8d5fc550c51ea4f473c5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:51:23 +0100 Subject: [PATCH 073/212] Bulk action run filtering working using CH --- .../app/presenters/v3/NextRunListPresenter.server.ts | 3 +++ .../app/services/runsReplicationService.server.ts | 1 + apps/webapp/app/services/runsRepository.server.ts | 11 ++++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index c7ee6639bc..ce05e0ab44 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -102,9 +102,11 @@ export class NextRunListPresenter { friendlyId: true, type: true, createdAt: true, + name: true, }, where: { projectId: projectId, + environmentId, }, orderBy: { createdAt: "desc", @@ -148,6 +150,7 @@ export class NextRunListPresenter { rootOnly, batchId, runIds, + bulkId, page: { size: pageSize, cursor, diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 8e32b2dbdd..495ea8faea 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -721,6 +721,7 @@ export class RunsReplicationService { expiration_ttl: run.ttl ?? "", output, concurrency_key: run.concurrencyKey ?? "", + bulk_action_group_ids: run.bulkActionGroupIds ?? [], _version: _version.toString(), _is_deleted: event === "delete" ? 1 : 0, }; diff --git a/apps/webapp/app/services/runsRepository.server.ts b/apps/webapp/app/services/runsRepository.server.ts index 901ae89753..3ca99a25bc 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository.server.ts @@ -6,6 +6,7 @@ import parseDuration from "parse-duration"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { type PrismaClient } from "~/db.server"; import { z } from "zod"; +import { BulkActionId } from "@trigger.dev/core/v3/isomorphic"; export type RunsRepositoryOptions = { clickhouse: ClickHouse; @@ -246,6 +247,10 @@ export class RunsRepository { } } + if (options.bulkId && options.bulkId.startsWith("bulk_")) { + convertedOptions.bulkId = BulkActionId.toId(options.bulkId); + } + // Show all runs if we are filtering by batchId or runId if (options.batchId || options.runIds?.length || options.scheduleId || options.tasks?.length) { convertedOptions.rootOnly = false; @@ -321,7 +326,11 @@ function applyRunFiltersToQueryBuilder( queryBuilder.where("batch_id = {batchId: String}", { batchId: options.batchId }); } - // TODO new bulk action filtering + if (options.bulkId) { + queryBuilder.where("hasAny(bulk_action_group_ids, {bulkActionGroupIds: Array(String)})", { + bulkActionGroupIds: [options.bulkId], + }); + } if (options.runIds && options.runIds.length > 0) { queryBuilder.where("friendly_id IN {runIds: Array(String)}", { From 6c43a87385b75b2079691acf0a90a4cd0cee48b7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:51:23 +0100 Subject: [PATCH 074/212] Bulk action run filtering working using CH From ccdf38579dbd1f05cc7f06aae7104c9ca8128049 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:51:23 +0100 Subject: [PATCH 075/212] Bulk action run filtering working using CH From 5fb35cbd0162b226e353ba0e8cfd72e7c57df5c5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:33:16 +0100 Subject: [PATCH 076/212] Replay setting the bulk id on the runs --- apps/webapp/app/runEngine/services/triggerTask.server.ts | 1 + apps/webapp/app/v3/services/replayTaskRun.server.ts | 2 +- apps/webapp/app/v3/services/triggerTaskV1.server.ts | 3 +++ internal-packages/run-engine/src/engine/index.ts | 2 ++ internal-packages/run-engine/src/engine/types.ts | 1 + packages/core/src/v3/schemas/api.ts | 1 + 6 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index b621d7dee6..939e3a6822 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -309,6 +309,7 @@ export class RunEngineTriggerTaskService { scheduleId: options.scheduleId, scheduleInstanceId: options.scheduleInstanceId, createdAt: options.overrideCreatedAt, + bulkActionId: body.options?.bulkActionId, }, this.prisma ); diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index 61da68723a..51501605cf 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -81,7 +81,7 @@ export class ReplayTaskRunService extends BaseService { undefined, lockToVersion: overrideOptions.version === "latest" ? undefined : overrideOptions.version, - //todo: add bulkActionId to the replay + bulkActionId: overrideOptions?.bulkActionId, }, }, { diff --git a/apps/webapp/app/v3/services/triggerTaskV1.server.ts b/apps/webapp/app/v3/services/triggerTaskV1.server.ts index 5a913838ef..5fc09a524e 100644 --- a/apps/webapp/app/v3/services/triggerTaskV1.server.ts +++ b/apps/webapp/app/v3/services/triggerTaskV1.server.ts @@ -443,6 +443,9 @@ export class TriggerTaskServiceV1 extends BaseService { scheduleId: options.scheduleId, scheduleInstanceId: options.scheduleInstanceId, createdAt: options.overrideCreatedAt, + bulkActionGroupIds: body.options?.bulkActionId + ? [body.options.bulkActionId] + : undefined, }, }); diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index ea8b2f0f65..37b8f09412 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -389,6 +389,7 @@ export class RunEngine { scheduleId, scheduleInstanceId, createdAt, + bulkActionId, }: TriggerParams, tx?: PrismaClientOrTransaction ): Promise { @@ -463,6 +464,7 @@ export class RunEngine { scheduleId, scheduleInstanceId, createdAt, + bulkActionGroupIds: bulkActionId ? [bulkActionId] : undefined, executionSnapshots: { create: { engine: "V2", diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 87612b2bfa..f9e2abbda7 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -146,6 +146,7 @@ export type TriggerParams = { scheduleId?: string; scheduleInstanceId?: string; createdAt?: Date; + bulkActionId?: string; }; export type EngineWorker = Worker; diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 7b348ba069..3521b8f955 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -134,6 +134,7 @@ export const TriggerTaskRequestBody = z.object({ ttl: z.string().or(z.number().nonnegative().int()).optional(), priority: z.number().optional(), releaseConcurrency: z.boolean().optional(), + bulkActionId: z.string().optional(), }) .optional(), }); From 7edc01531fdc78e905d21523667341954f2166da Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:33:16 +0100 Subject: [PATCH 077/212] Replay setting the bulk id on the runs From 00058a18bae2ba6e00fc5c240a0b009da8a871ad Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 8 Jul 2025 17:33:16 +0100 Subject: [PATCH 078/212] Replay setting the bulk id on the runs From 63c0b1b5fc009bcf4579fcba1bac5fca2f98c67b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 11:22:47 +0100 Subject: [PATCH 079/212] Properly cap the time when doing a bulk action --- .../bulk/createBulkActionV2.server.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts index fe87523356..afada5ccff 100644 --- a/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts +++ b/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts @@ -11,6 +11,8 @@ import { logger } from "@trigger.dev/sdk"; import { CancelTaskRunService } from "../cancelTaskRun.server"; import { tryCatch } from "@trigger.dev/core"; import { ReplayTaskRunService } from "../replayTaskRun.server"; +import { timeFilters } from "~/components/runs/v3/SharedFilters"; +import parseDuration from "parse-duration"; export class BulkActionService extends BaseService { public async create( @@ -276,11 +278,33 @@ async function getFilters(payload: CreateBulkActionPayload, request: Request) { filters.cursor = undefined; filters.direction = undefined; - // If there isn't a time period or to date, we set the to date to now - // Otherwise this could run forever if lots of new runs are being created - if (!filters.period && !filters.to) { + const { period, from, to } = timeFilters({ + period: filters.period, + from: filters.from, + to: filters.to, + }); + + // We fix the time period to a from/to date + if (period) { + const periodMs = parseDuration(period); + if (!periodMs) { + throw new Error(`Invalid period: ${period}`); + } + + const to = new Date(); + const from = new Date(to.getTime() - periodMs); + filters.from = from.getTime(); + filters.to = to.getTime(); + filters.period = undefined; + return filters; + } + + // If no to date is set, we lock it to now + if (!filters.to) { filters.to = Date.now(); } + filters.period = undefined; + return filters; } From c5ce66f729a5e8b1d12925a55df7508beed221c7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 11:22:47 +0100 Subject: [PATCH 080/212] Properly cap the time when doing a bulk action From 096317d43695a27ccd247283ff47c844e94728d3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 11:22:47 +0100 Subject: [PATCH 081/212] Properly cap the time when doing a bulk action From f39bf845df535da5ff645ffe574d90e609ce7a3d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 11:32:56 +0100 Subject: [PATCH 082/212] If the bulk action isn't recent, add it to the dropdown anyway --- .../v3/NextRunListPresenter.server.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index ce05e0ab44..cacbbfda85 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -96,7 +96,6 @@ export class NextRunListPresenter { const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId); //get possible bulk actions - // TODO: we should replace this with the new bulk stuff and make it environment scoped const bulkActionsAsync = this.replica.bulkActionGroup.findMany({ select: { friendlyId: true, @@ -120,6 +119,27 @@ export class NextRunListPresenter { findDisplayableEnvironment(environmentId, userId), ]); + // If the bulk action isn't in the most recent ones, add it separately + if (bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)) { + const selectedBulkAction = await this.replica.bulkActionGroup.findFirst({ + select: { + friendlyId: true, + type: true, + createdAt: true, + name: true, + }, + where: { + friendlyId: bulkId, + projectId, + environmentId, + }, + }); + + if (selectedBulkAction) { + bulkActions.push(selectedBulkAction); + } + } + if (!displayableEnvironment) { throw new ServiceValidationError("No environment found"); } From 625f6a4860bc68e3d3db91a17b175b4068546610 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 11:32:56 +0100 Subject: [PATCH 083/212] If the bulk action isn't recent, add it to the dropdown anyway From 34e4d8f83591f6df7ddfb91fe3bc877a0efdedf7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 11:32:56 +0100 Subject: [PATCH 084/212] If the bulk action isn't recent, add it to the dropdown anyway From dbb577ae2a89d4677d176f4b8e8fc1d05d6a78d2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 12:29:52 +0100 Subject: [PATCH 085/212] Blank version of the bulk actions page --- .../app/components/BlankStatePanels.tsx | 32 ++++- .../app/components/navigation/SideMenu.tsx | 10 ++ .../app/components/runs/v3/RunFilters.tsx | 5 +- .../route.tsx | 109 ++++++++++++++++++ apps/webapp/app/utils/pathBuilder.ts | 8 ++ apps/webapp/tailwind.config.js | 2 + 6 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index f3a4b3faa5..29f4419efc 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -19,6 +19,7 @@ import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPrese import { docsPath, v3BillingPath, + v3CreateBulkActionPath, v3EnvironmentPath, v3EnvironmentVariablesPath, v3NewProjectAlertPath, @@ -29,7 +30,7 @@ import { environmentFullTitle } from "./environments/EnvironmentLabel"; import { Feedback } from "./Feedback"; import { EnvironmentSelector } from "./navigation/EnvironmentSelector"; import { Button, LinkButton } from "./primitives/Buttons"; -import { Header1 } from "./primitives/Headers"; +import { Header1, Header2 } from "./primitives/Headers"; import { InfoPanel } from "./primitives/InfoPanel"; import { Paragraph } from "./primitives/Paragraph"; import { StepNumber } from "./primitives/StepNumber"; @@ -569,3 +570,32 @@ export function SwitcherPanel({ title = "Switch to a deployed environment" }: {
); } + +export function BulkActionsNone() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+
+ Create a bulk action +
+ + New bulk action + +
+
+ + Hello + + James + + Ritchie +
+ ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index efa43603dc..de7c045ad1 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -49,6 +49,7 @@ import { v3ApiKeysPath, v3BatchesPath, v3BillingPath, + v3BulkActionsPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, @@ -86,6 +87,8 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { ListChecks } from "lucide-react"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -270,6 +273,13 @@ export function SideMenu({ + ; case "bulkId": - return ; + return ; case "period": return ; case "from": @@ -263,7 +264,7 @@ const filterTypes = [ { name: "run", title: "Run ID", icon: }, { name: "batch", title: "Batch ID", icon: }, { name: "schedule", title: "Schedule ID", icon: }, - { name: "bulk", title: "Bulk action", icon: }, + { name: "bulk", title: "Bulk action", icon: }, ] as const; type FilterType = (typeof filterTypes)[number]["name"]; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx new file mode 100644 index 0000000000..52920dc293 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -0,0 +1,109 @@ +import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { BulkActionsNone } from "~/components/BlankStatePanels"; +import { CodeBlock } from "~/components/code/CodeBlock"; +import { InlineCode } from "~/components/code/InlineCode"; +import { + EnvironmentCombo, + environmentFullTitle, + environmentTextClassName, +} from "~/components/environments/EnvironmentLabel"; +import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal"; +import { + MainCenteredContainer, + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "~/components/primitives/Accordion"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import * as Property from "~/components/primitives/PropertyTable"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { docsPath, EnvironmentParamSchema, v3CreateBulkActionPath } from "~/utils/pathBuilder"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Bulk actions | Trigger.dev`, + }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, envParam } = EnvironmentParamSchema.parse(params); + + try { + return typedjson({ + bulkActions: [], + }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export default function Page() { + const { bulkActions } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( + + + + + + + + Bulk actions docs + + + New bulk action + + + + + {bulkActions.length === 0 ? ( + + + + ) : ( + <> + )} + + + ); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index f27a02135f..5fe6f1e5eb 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -167,6 +167,14 @@ export function v3ApiKeysPath( return `${v3EnvironmentPath(organization, project, environment)}/apikeys`; } +export function v3BulkActionsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/bulk-actions`; +} + export function v3EnvironmentVariablesPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 1e47ecf75b..7ca81fd8ee 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -167,6 +167,7 @@ const alerts = colors.red[500]; const projectSettings = colors.blue[500]; const orgSettings = colors.blue[500]; const docs = colors.blue[500]; +const bulkActions = colors.emerald[500]; /** Other variables */ const radius = "0.5rem"; @@ -242,6 +243,7 @@ module.exports = { projectSettings, orgSettings, docs, + bulkActions, }, focusStyles: { outline: "1px solid", From 8dc87bd63743c091c3c81db1c9048dbbaf9be8ed Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 12:29:52 +0100 Subject: [PATCH 086/212] Blank version of the bulk actions page From f4a713c47d957aa10df9df292471c5bc95f96cfd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 12:29:52 +0100 Subject: [PATCH 087/212] Blank version of the bulk actions page From ca00ab4f9eb8d08a893e9bb7258fd8d77e9953d4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 13:03:42 +0100 Subject: [PATCH 088/212] Individually selected runs working --- .../app/components/runs/v3/TaskRunsTable.tsx | 12 ++++---- .../route.tsx | 30 +------------------ ...ectParam.env.$envParam.runs.bulkaction.tsx | 3 ++ .../app/services/runsRepository.server.ts | 4 +-- 4 files changed, 12 insertions(+), 37 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index c85963edcb..6202f61b38 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -93,7 +93,7 @@ export function TaskRunsTable({ if (event.shiftKey) { const oldItem = runs.at(index - 1); const newItem = runs.at(index - 2); - const itemsIds = [oldItem?.id, newItem?.id].filter(Boolean); + const itemsIds = [oldItem?.friendlyId, newItem?.friendlyId].filter(Boolean); select(itemsIds); } } else if (event.key === "ArrowDown" && index < checkboxes.current.length - 1) { @@ -102,7 +102,7 @@ export function TaskRunsTable({ if (event.shiftKey) { const oldItem = runs.at(index - 1); const newItem = runs.at(index); - const itemsIds = [oldItem?.id, newItem?.id].filter(Boolean); + const itemsIds = [oldItem?.friendlyId, newItem?.friendlyId].filter(Boolean); select(itemsIds); } } @@ -118,9 +118,9 @@ export function TaskRunsTable({ {runs.length > 0 && ( r.id))} + checked={hasAll(runs.map((r) => r.friendlyId))} onChange={(element) => { - const ids = runs.map((r) => r.id); + const ids = runs.map((r) => r.friendlyId); const checked = element.currentTarget.checked; if (checked) { select(ids); @@ -297,9 +297,9 @@ export function TaskRunsTable({ {allowSelection && ( { - toggle(run.id); + toggle(run.friendlyId); }} ref={(r) => { checkboxes.current[index + 1] = r; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index 52920dc293..e606aa9f70 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -4,41 +4,13 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { BulkActionsNone } from "~/components/BlankStatePanels"; -import { CodeBlock } from "~/components/code/CodeBlock"; -import { InlineCode } from "~/components/code/InlineCode"; -import { - EnvironmentCombo, - environmentFullTitle, - environmentTextClassName, -} from "~/components/environments/EnvironmentLabel"; -import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal"; -import { - MainCenteredContainer, - MainHorizontallyCenteredContainer, - PageBody, - PageContainer, -} from "~/components/layout/AppLayout"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "~/components/primitives/Accordion"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { LinkButton } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; -import { ClipboardField } from "~/components/primitives/ClipboardField"; -import { Header2 } from "~/components/primitives/Headers"; -import { Hint } from "~/components/primitives/Hint"; -import { InputGroup } from "~/components/primitives/InputGroup"; -import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import * as Property from "~/components/primitives/PropertyTable"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { cn } from "~/utils/cn"; import { docsPath, EnvironmentParamSchema, v3CreateBulkActionPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 60f309bfdd..eadfc8a147 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -212,6 +212,9 @@ export function CreateBulkActionInspector({
+ {Array.from(selectedItems).map((runId) => { + return ; + })} ( if (options.runIds && options.runIds.length > 0) { queryBuilder.where("friendly_id IN {runIds: Array(String)}", { - runIds: options.runIds, + runIds: options.runIds.map((runId) => RunId.toFriendlyId(runId)), }); } } From 23ee423845f07198d0389493add8d7f00b65e88e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 13:03:42 +0100 Subject: [PATCH 089/212] Individually selected runs working From 28ef8f30aeee20caa631fc5d2eeb5a9e6c75e9f9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 13:03:42 +0100 Subject: [PATCH 090/212] Individually selected runs working From 077dad2a36ba451f8d874d2942ccc86a4f942525 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 13:06:52 +0100 Subject: [PATCH 091/212] Use selected mode if runs are checked --- .../route.tsx | 3 ++- apps/webapp/app/utils/pathBuilder.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index c1c5144964..c7919d7239 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -203,7 +203,8 @@ export default function Page() { organization, project, environment, - filters + filters, + selectedItems.size > 0 ? "selected" : undefined )} LeadingIcon={ListCheckedIcon} className={selectedItems.size > 0 ? "pr-1" : undefined} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 5fe6f1e5eb..cbc591607e 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -249,10 +249,14 @@ export function v3CreateBulkActionPath( organization: OrgForPath, project: ProjectForPath, environment: EnvironmentForPath, - filters?: TaskRunListSearchFilters + filters?: TaskRunListSearchFilters, + mode?: "selected" | "filters" ) { const searchParams = objectToSearchParams(filters) ?? new URLSearchParams(); searchParams.set("bulkInspector", "show"); + if (mode) { + searchParams.set("mode", mode); + } const query = `?${searchParams.toString()}`; return `${v3RunsPath(organization, project, environment)}${query}`; } From 1f274c5c98b482fd458921889f6a3363bafed371 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 13:06:52 +0100 Subject: [PATCH 092/212] Use selected mode if runs are checked From 93febfeb1b2441dfe7a6cd459bee4f1f8bd15c57 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 13:06:52 +0100 Subject: [PATCH 093/212] Use selected mode if runs are checked From df5abd29750b29490ed08f245a25638025711c17 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:08:46 +0100 Subject: [PATCH 094/212] Added the modal --- .../route.tsx | 1 - ...ectParam.env.$envParam.runs.bulkaction.tsx | 100 +++++++++++++----- 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index e606aa9f70..08a998da3b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -50,7 +50,6 @@ export default function Page() { - ; }) { + const [isDialogOpen, setIsDialogOpen] = useState(false); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -193,6 +201,7 @@ export function CreateBulkActionInspector({ method="post" action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/bulkaction${location.search}`} className="h-full" + id="bulk-action-form" >
@@ -213,7 +222,7 @@ export function CreateBulkActionInspector({
{Array.from(selectedItems).map((runId) => { - return ; + return ; })} @@ -296,27 +305,70 @@ export function CreateBulkActionInspector({
- + + + + + + You've exceeded your limit +
+ + + {action === "replay" + ? "All matching runs will be replayed." + : "Runs that are still in progress will be canceled. If a run finishes before this bulk action processes it, it can’t be canceled."} + +
+ + + + +
+
From b8bcc3107dc0ee256e5ce849718ebd50daedd875 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:08:46 +0100 Subject: [PATCH 095/212] Added the modal From c096d52d6ef7480ca028c801a327966c384a8a91 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:08:46 +0100 Subject: [PATCH 096/212] Added the modal From fb338e7806ce0b4a3eca558f860ad09f75604551 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:08:58 +0100 Subject: [PATCH 097/212] Marked the old bulk actions stuff as deprecated --- apps/webapp/app/services/worker.server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts index 83c9eab023..902d752ed0 100644 --- a/apps/webapp/app/services/worker.server.ts +++ b/apps/webapp/app/services/worker.server.ts @@ -244,6 +244,7 @@ function getWorkerQueue() { return await service.call(payload.deploymentId); }, }, + // @deprecated, new bulk actions use the new bulk actions worker "v3.performBulkAction": { priority: 0, maxAttempts: 3, @@ -253,6 +254,7 @@ function getWorkerQueue() { return await service.call(payload.bulkActionGroupId); }, }, + // @deprecated, new bulk actions use the new bulk actions worker "v3.performBulkActionItem": { priority: 0, maxAttempts: 3, From 015e197bcc1baeb9f6693f289541e854b1e60a24 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:08:58 +0100 Subject: [PATCH 098/212] Marked the old bulk actions stuff as deprecated From 92c929c44d16141dd104b4ae73e3a29c6f5fba62 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:08:58 +0100 Subject: [PATCH 099/212] Marked the old bulk actions stuff as deprecated From a8368e6d9cc61d6ce755c7ff35466a99664b6456 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:16:26 +0100 Subject: [PATCH 100/212] Renamed bulk action file --- ...lug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx | 2 +- apps/webapp/app/v3/commonWorker.server.ts | 2 +- .../{createBulkActionV2.server.ts => BulkActionV2.server.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/webapp/app/v3/services/bulk/{createBulkActionV2.server.ts => BulkActionV2.server.ts} (100%) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 3217f6952c..41f4762d32 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -51,7 +51,7 @@ import { logger } from "~/services/logger.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { findProjectBySlug } from "~/models/project.server"; import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server"; -import { BulkActionService } from "~/v3/services/bulk/createBulkActionV2.server"; +import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server"; import { tryCatch } from "@trigger.dev/core"; import { Dialog, diff --git a/apps/webapp/app/v3/commonWorker.server.ts b/apps/webapp/app/v3/commonWorker.server.ts index dc256a9666..a2fae9c73c 100644 --- a/apps/webapp/app/v3/commonWorker.server.ts +++ b/apps/webapp/app/v3/commonWorker.server.ts @@ -20,7 +20,7 @@ import { ResumeBatchRunService } from "./services/resumeBatchRun.server"; import { ResumeTaskDependencyService } from "./services/resumeTaskDependency.server"; import { RetryAttemptService } from "./services/retryAttempt.server"; import { TimeoutDeploymentService } from "./services/timeoutDeployment.server"; -import { BulkActionService } from "./services/bulk/createBulkActionV2.server"; +import { BulkActionService } from "./services/bulk/BulkActionV2.server"; function initializeWorker() { const redisOptions = { diff --git a/apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts similarity index 100% rename from apps/webapp/app/v3/services/bulk/createBulkActionV2.server.ts rename to apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts From 4b4aa6085090a00b7e0f7240aa6fc5ce4c196ef3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:16:26 +0100 Subject: [PATCH 101/212] Renamed bulk action file From 72d6d098ef69ca56feb41685dc13efa4f8668def Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 14:16:26 +0100 Subject: [PATCH 102/212] Renamed bulk action file From 180d9be12175e6a0e914f73f3c6ce8b074295393 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 15:19:39 +0100 Subject: [PATCH 103/212] Bulk run filter with the name and a default --- .../app/components/runs/v3/BulkAction.tsx | 10 ++++---- .../app/components/runs/v3/RunFilters.tsx | 23 ++++++++++++++----- .../v3/NextRunListPresenter.server.ts | 3 +-- ...ectParam.env.$envParam.runs.bulkaction.tsx | 4 +--- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/BulkAction.tsx b/apps/webapp/app/components/runs/v3/BulkAction.tsx index ab570b4fa1..4211bd18ae 100644 --- a/apps/webapp/app/components/runs/v3/BulkAction.tsx +++ b/apps/webapp/app/components/runs/v3/BulkAction.tsx @@ -1,5 +1,5 @@ import { ArrowPathIcon, NoSymbolIcon } from "@heroicons/react/20/solid"; -import { BulkActionType } from "@trigger.dev/database"; +import { type BulkActionType } from "@trigger.dev/database"; import assertNever from "assert-never"; import { cn } from "~/utils/cn"; @@ -7,21 +7,23 @@ export function BulkActionStatusCombo({ type, className, iconClassName, + labelClassName, }: { type: BulkActionType; className?: string; iconClassName?: string; + labelClassName?: string; }) { return ( - + ); } -export function BulkActionLabel({ type }: { type: BulkActionType }) { - return {bulkActionTitle(type)}; +export function BulkActionLabel({ type, className }: { type: BulkActionType; className?: string }) { + return {bulkActionTitle(type)}; } export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) { diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 6eaf10e131..8faf6e3062 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -56,6 +56,7 @@ import { } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { cn } from "~/utils/cn"; export const RunStatus = z.enum(allTaskRunStatuses); @@ -218,6 +219,7 @@ type RunFiltersProps = { id: string; type: BulkActionType; createdAt: Date; + name: string; }[]; rootOnlyDefault: boolean; hasFilters: boolean; @@ -608,7 +610,7 @@ function BulkActionsDropdown({ {trigger} { if (onClose) { onClose(); @@ -621,11 +623,20 @@ function BulkActionsDropdown({ None - {filtered.map((item, index) => ( - -
- - + {filtered.map((item) => ( + +
+ + {item.name} + +
+ + +
))} diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index cacbbfda85..b799c99a19 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -75,8 +75,6 @@ export class NextRunListPresenter { to, }); - const periodMs = time.period ? parseDuration(time.period) : undefined; - const hasStatusFilters = statuses && statuses.length > 0; const hasFilters = @@ -245,6 +243,7 @@ export class NextRunListPresenter { id: bulkAction.friendlyId, type: bulkAction.type, createdAt: bulkAction.createdAt, + name: bulkAction.name || bulkAction.friendlyId, })), filters: { tasks: tasks || [], diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 41f4762d32..7c9e648365 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -330,7 +330,7 @@ export function CreateBulkActionInspector({ - You've exceeded your limit + {action === "replay" ? "Replay runs" : "Cancel runs"}
{action === "replay" ? ( From be492c952956cc1ec4589964e3dcab3f60d7b06e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 15:19:39 +0100 Subject: [PATCH 104/212] Bulk run filter with the name and a default From 4656e5e0025c310905a756a5762b383f161e8d82 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 15:19:39 +0100 Subject: [PATCH 105/212] Bulk run filter with the name and a default From 8688534879a02cb6244d2f4ddd7c819044bba9f2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 16:42:34 +0100 Subject: [PATCH 106/212] WIP on bulk actions page --- .../app/components/runs/v3/BulkAction.tsx | 68 ++++- .../app/components/runs/v3/RunFilters.tsx | 4 +- .../v3/BulkActionListPresenter.server.ts | 62 +++++ .../v3/BulkActionPresenter.server.ts | 45 ++++ .../route.tsx | 185 ++++++++++++++ .../route.tsx | 238 +++++++++++++++++- apps/webapp/app/utils/pathBuilder.ts | 15 +- .../migration.sql | 2 + .../database/prisma/schema.prisma | 2 + 9 files changed, 605 insertions(+), 16 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/BulkActionListPresenter.server.ts create mode 100644 apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx create mode 100644 internal-packages/database/prisma/migrations/20250709131914_bulk_action_group_environment_id_created_at_index/migration.sql diff --git a/apps/webapp/app/components/runs/v3/BulkAction.tsx b/apps/webapp/app/components/runs/v3/BulkAction.tsx index 4211bd18ae..d9f0a78441 100644 --- a/apps/webapp/app/components/runs/v3/BulkAction.tsx +++ b/apps/webapp/app/components/runs/v3/BulkAction.tsx @@ -1,9 +1,10 @@ -import { ArrowPathIcon, NoSymbolIcon } from "@heroicons/react/20/solid"; -import { type BulkActionType } from "@trigger.dev/database"; +import { ArrowPathIcon, CheckCircleIcon, NoSymbolIcon } from "@heroicons/react/20/solid"; +import { BulkActionStatus, type BulkActionType } from "@trigger.dev/database"; import assertNever from "assert-never"; +import { Spinner } from "~/components/primitives/Spinner"; import { cn } from "~/utils/cn"; -export function BulkActionStatusCombo({ +export function BulkActionTypeCombo({ type, className, iconClassName, @@ -23,7 +24,7 @@ export function BulkActionStatusCombo({ } export function BulkActionLabel({ type, className }: { type: BulkActionType; className?: string }) { - return {bulkActionTitle(type)}; + return {bulkActionTitle(type)}; } export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) { @@ -73,3 +74,62 @@ export function bulkActionVerb(type: BulkActionType): string { } } } + +export function BulkActionStatusCombo({ + status, + className, + iconClassName, + labelClassName, +}: { + status: BulkActionStatus; + className?: string; + iconClassName?: string; + labelClassName?: string; +}) { + return ( + + + + + ); +} + +export function BulkActionStatusIcon({ + status, + className, +}: { + status: BulkActionStatus; + className: string; +}) { + switch (status) { + case "PENDING": + return ; + case "COMPLETED": + return ; + case "ABORTED": + return ; + default: { + assertNever(status); + } + } +} + +export function BulkActionStatusLabel({ + status, + className, +}: { + status: BulkActionStatus; + className?: string; +}) { + switch (status) { + case "PENDING": + return In progress; + case "COMPLETED": + return Completed; + case "ABORTED": + return Aborted; + default: { + assertNever(status); + } + } +} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 8faf6e3062..498a89859a 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -45,7 +45,7 @@ import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags"; import { Button } from "../../primitives/Buttons"; -import { BulkActionStatusCombo } from "./BulkAction"; +import { BulkActionTypeCombo } from "./BulkAction"; import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters"; import { allTaskRunStatuses, @@ -630,7 +630,7 @@ function BulkActionsDropdown({ {item.name}
- +>["bulkActions"][number]; + +export class BulkActionListPresenter extends BasePresenter { + public async call({ environmentId, page }: BulkActionListOptions) { + const totalCount = await this._replica.bulkActionGroup.count({ + where: { + environmentId, + }, + }); + + const bulkActions = await this._replica.bulkActionGroup.findMany({ + select: { + friendlyId: true, + name: true, + status: true, + type: true, + createdAt: true, + completedAt: true, + totalCount: true, + user: { + select: { + name: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + where: { + environmentId, + }, + orderBy: { + createdAt: "desc", + }, + skip: ((page ?? 1) - 1) * DEFAULT_PAGE_SIZE, + take: DEFAULT_PAGE_SIZE, + }); + + return { + currentPage: page ?? 1, + totalPages: Math.ceil(totalCount / DEFAULT_PAGE_SIZE), + totalCount: totalCount, + bulkActions: bulkActions.map((bulkAction) => ({ + ...bulkAction, + user: bulkAction.user + ? { name: getUsername(bulkAction.user), avatarUrl: bulkAction.user.avatarUrl } + : undefined, + })), + }; + } +} diff --git a/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts b/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts new file mode 100644 index 0000000000..aff7939149 --- /dev/null +++ b/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts @@ -0,0 +1,45 @@ +import { getUsername } from "~/utils/username"; +import { BasePresenter } from "./basePresenter.server"; + +type BulkActionOptions = { + environmentId: string; + bulkActionId: string; +}; + +export class BulkActionPresenter extends BasePresenter { + public async call({ environmentId, bulkActionId }: BulkActionOptions) { + const bulkAction = await this._replica.bulkActionGroup.findFirst({ + select: { + friendlyId: true, + name: true, + status: true, + type: true, + createdAt: true, + completedAt: true, + totalCount: true, + user: { + select: { + name: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + where: { + environmentId, + friendlyId: bulkActionId, + }, + }); + + if (!bulkAction) { + throw new Error("Bulk action not found"); + } + + return { + ...bulkAction, + user: bulkAction.user + ? { name: getUsername(bulkAction.user), avatarUrl: bulkAction.user.avatarUrl } + : undefined, + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx new file mode 100644 index 0000000000..a0caf2f4d8 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -0,0 +1,185 @@ +import { ArrowPathIcon, BookOpenIcon } from "@heroicons/react/20/solid"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { InlineCode } from "~/components/code/InlineCode"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import * as Property from "~/components/primitives/PropertyTable"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; +import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; +import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType"; +import { UserAvatar } from "~/components/UserProfilePhoto"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { BulkActionPresenter } from "~/presenters/v3/BulkActionPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { + EnvironmentParamSchema, + v3BulkActionsPath, + v3CreateBulkActionPath, + v3RunsPath, +} from "~/utils/pathBuilder"; + +const BulkActionParamSchema = EnvironmentParamSchema.extend({ + bulkActionParam: z.string(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + + const { organizationSlug, projectParam, envParam, bulkActionParam } = + BulkActionParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + try { + const presenter = new BulkActionPresenter(); + const [error, data] = await tryCatch( + presenter.call({ + environmentId: environment.id, + bulkActionId: bulkActionParam, + }) + ); + + if (error) { + throw new Error(error.message); + } + + return typedjson({ bulkAction: data }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export default function Page() { + const { bulkAction } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+
+ + {bulkAction.name || bulkAction.friendlyId} + + +
+
+
+
+ + + ID + {bulkAction.friendlyId} + + + Bulk action + + + + + + User + + {bulkAction.user ? ( +
+ + {bulkAction.user.name} +
+ ) : ( + "–" + )} +
+
+ + Created + + + + + + Completed + + {bulkAction.completedAt ? : "–"} + + +
+
+
+
+
+ + Replay runs + + + + View runs + +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index 08a998da3b..368b5023a2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -1,17 +1,60 @@ import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction } from "@remix-run/react"; +import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core"; +import { schedules } from "@trigger.dev/sdk"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { BulkActionsNone } from "~/components/BlankStatePanels"; +import { InlineCode } from "~/components/code/InlineCode"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { LinkButton } from "~/components/primitives/Buttons"; +import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; +import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; +import { + ScheduleTypeIcon, + scheduleTypeName, + ScheduleTypeCombo, +} from "~/components/runs/v3/ScheduleType"; +import { UserAvatar } from "~/components/UserProfilePhoto"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { + BulkActionListItem, + BulkActionListPresenter, +} from "~/presenters/v3/BulkActionListPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { docsPath, EnvironmentParamSchema, v3CreateBulkActionPath } from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; +import { + docsPath, + EnvironmentParamSchema, + v3BulkActionPath, + v3CreateBulkActionPath, + v3SchedulePath, +} from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -21,14 +64,42 @@ export const meta: MetaFunction = () => { ]; }; +const SearchParamsSchema = z.object({ + page: z.coerce.number().optional(), +}); + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } try { - return typedjson({ - bulkActions: [], - }); + const url = new URL(request.url); + const { page } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams)); + + const presenter = new BulkActionListPresenter(); + const [error, data] = await tryCatch( + presenter.call({ + environmentId: environment.id, + page, + }) + ); + + if (error) { + throw new Error(error.message); + } + + return typedjson(data); } catch (error) { console.error(error); throw new Response(undefined, { @@ -39,10 +110,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { bulkActions } = useTypedLoaderData(); + const { bulkActions, currentPage, totalPages, totalCount } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); + const { bulkActionParam } = useParams(); + const isShowingInspector = bulkActionParam !== undefined; return ( @@ -61,20 +134,167 @@ export default function Page() { variant="primary/small" LeadingIcon={PlusIcon} to={v3CreateBulkActionPath(organization, project, environment)} + shortcut={{ + key: "n", + }} > New bulk action - + {bulkActions.length === 0 ? ( ) : ( - <> + + +
1 ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[auto_1fr]" + )} + > +
+
+ +
+
+ + + {totalPages > 1 && ( +
1 && "justify-end border-t border-grid-dimmed px-2 py-3" + )} + > + +
+ )} +
+
+ {isShowingInspector && ( + <> + + + + + + )} +
)}
); } + +function BulkActionsTable({ bulkActions }: { bulkActions: BulkActionListItem[] }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { bulkActionParam } = useParams(); + + return ( + + + + ID + Name + +
+
+ +
+ + The bulk action is currently in progress. They can take some time if there are + lots of runs. + +
+
+
+ +
+ + The bulk action has completed successfully. + +
+
+
+ +
+ + The bulk action was aborted. + +
+ + } + > + Status +
+ Bulk action + Runs + User + Created + Completed +
+
+ + {bulkActions.length === 0 ? ( + There are no matching bulk actions + ) : ( + bulkActions.map((bulkAction) => { + const path = v3BulkActionPath(organization, project, environment, bulkAction); + const isSelected = bulkActionParam === bulkAction.friendlyId; + + return ( + + + {bulkAction.friendlyId} + + {bulkAction.name || "–"} + + + + + + + {bulkAction.totalCount} + + {bulkAction.user ? ( +
+ + {bulkAction.user.name} +
+ ) : ( + "–" + )} +
+ + + + + {bulkAction.completedAt ? : "–"} + +
+ ); + }) + )} +
+
+ ); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index cbc591607e..93610360bc 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -175,6 +175,15 @@ export function v3BulkActionsPath( return `${v3EnvironmentPath(organization, project, environment)}/bulk-actions`; } +export function v3BulkActionPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + bulkAction: { friendlyId: string } +) { + return `${v3BulkActionsPath(organization, project, environment)}/${bulkAction.friendlyId}`; +} + export function v3EnvironmentVariablesPath( organization: OrgForPath, project: ProjectForPath, @@ -250,13 +259,17 @@ export function v3CreateBulkActionPath( project: ProjectForPath, environment: EnvironmentForPath, filters?: TaskRunListSearchFilters, - mode?: "selected" | "filters" + mode?: "selected" | "filters", + action?: "replay" | "cancel" ) { const searchParams = objectToSearchParams(filters) ?? new URLSearchParams(); searchParams.set("bulkInspector", "show"); if (mode) { searchParams.set("mode", mode); } + if (action) { + searchParams.set("action", action); + } const query = `?${searchParams.toString()}`; return `${v3RunsPath(organization, project, environment)}${query}`; } diff --git a/internal-packages/database/prisma/migrations/20250709131914_bulk_action_group_environment_id_created_at_index/migration.sql b/internal-packages/database/prisma/migrations/20250709131914_bulk_action_group_environment_id_created_at_index/migration.sql new file mode 100644 index 0000000000..422ee561e7 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250709131914_bulk_action_group_environment_id_created_at_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "BulkActionGroup_environmentId_createdAt_idx" ON "BulkActionGroup" ("environmentId", "createdAt" DESC); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 95f48efe62..7d809fb110 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2016,6 +2016,8 @@ model BulkActionGroup { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([environmentId, createdAt(sort: Desc)]) } enum BulkActionType { From 460479b086ad41bc29e30e1b1084ab4c38e81469 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 16:42:34 +0100 Subject: [PATCH 107/212] WIP on bulk actions page From a8c3a84f701d895868f6a3634be15813561b4827 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 9 Jul 2025 16:42:34 +0100 Subject: [PATCH 108/212] WIP on bulk actions page From 02e3024b972f75f4ceb350b78a39312a179ffb24 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 11:13:53 +0100 Subject: [PATCH 109/212] Updated panel, added new truncated id component --- .../primitives/TruncatedCopyableValue.tsx | 26 +++++++++++++++++++ .../app/components/runs/v3/TaskRunsTable.tsx | 16 ++---------- .../route.tsx | 20 ++++++++++---- .../route.tsx | 3 ++- ...ectParam.env.$envParam.runs.bulkaction.tsx | 17 ++++++------ 5 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 apps/webapp/app/components/primitives/TruncatedCopyableValue.tsx diff --git a/apps/webapp/app/components/primitives/TruncatedCopyableValue.tsx b/apps/webapp/app/components/primitives/TruncatedCopyableValue.tsx new file mode 100644 index 0000000000..c2270fd490 --- /dev/null +++ b/apps/webapp/app/components/primitives/TruncatedCopyableValue.tsx @@ -0,0 +1,26 @@ +import { cn } from "~/utils/cn"; +import { CopyableText } from "./CopyableText"; +import { SimpleTooltip } from "./Tooltip"; + +export function TruncatedCopyableValue({ + value, + className, + length = 8, +}: { + value: string; + className?: string; + length?: number; +}) { + return ( + + + + } + asChild + disableHoverableContent + /> + ); +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 6202f61b38..2301bd61dc 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -54,6 +54,7 @@ import { import { useEnvironment } from "~/hooks/useEnvironment"; import { CopyableText } from "~/components/primitives/CopyableText"; import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue"; type RunsTableProps = { total: number; @@ -309,20 +310,7 @@ export function TaskRunsTable({ )} - - - - } - asChild - disableHoverableContent - /> + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index a0caf2f4d8..43225be7e3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -7,7 +7,9 @@ import { ExitIcon } from "~/assets/icons/ExitIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { InlineCode } from "~/components/code/InlineCode"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; -import { LinkButton } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue"; +import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -21,7 +23,7 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; +import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType"; import { UserAvatar } from "~/components/UserProfilePhoto"; @@ -90,7 +92,7 @@ export default function Page() { const environment = useEnvironment(); return ( -
+
{bulkAction.name || bulkAction.friendlyId} @@ -104,13 +106,21 @@ export default function Page() { className="pl-1" />
+
+ + {bulkAction.status !== "PENDING" ? ( + + ) : null} +
ID - {bulkAction.friendlyId} + + + Bulk action @@ -160,7 +170,7 @@ export default function Page() { { bulkId: bulkAction.friendlyId, }, - "filters", + undefined, "replay" )} variant="tertiary/medium" diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index 368b5023a2..b6ebb32e27 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -11,6 +11,7 @@ import { InlineCode } from "~/components/code/InlineCode"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { LinkButton } from "~/components/primitives/Buttons"; +import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue"; import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; @@ -260,7 +261,7 @@ function BulkActionsTable({ bulkActions }: { bulkActions: BulkActionListItem[] } className={isSelected ? "bg-grid-dimmed" : undefined} > - {bulkAction.friendlyId} + {bulkAction.name || "–"} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 7c9e648365..db91c54726 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -193,8 +193,9 @@ export function CreateBulkActionInspector({ const closedSearchParams = new URLSearchParams(location.search); closedSearchParams.delete("bulkInspector"); - const impactedCount = + const impactedCountElement = mode === "selected" ? selectedItems.size : ; + const impactedCount = mode === "selected" ? selectedItems.size : data?.count ?? 0; return (
@@ -356,12 +357,12 @@ export function CreateBulkActionInspector({ type="submit" form="bulk-action-form" variant={action === "replay" ? "primary/medium" : "danger/medium"} - disabled={impactedCount === 0} + disabled={impactedCountElement === 0} > {action === "replay" ? ( - Replay {impactedCount} runs + Replay {impactedCountElement} runs ) : ( - Cancel {impactedCount} runs + Cancel {impactedCountElement} runs )} From a7599038d5691675b299654d5491d59ae793e972 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 11:13:53 +0100 Subject: [PATCH 110/212] Updated panel, added new truncated id component From 49918fe10ed0c7cbaf30ac31b213a899dfecf8b0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 11:13:53 +0100 Subject: [PATCH 111/212] Updated panel, added new truncated id component From 395ce8120163e72b4154b324e6be49b898e30444 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 9 Jul 2025 17:01:13 +0100 Subject: [PATCH 112/212] Style improvements to the radio buttons --- apps/webapp/app/components/primitives/RadioButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx index 90418e62d3..d605d0883e 100644 --- a/apps/webapp/app/components/primitives/RadioButton.tsx +++ b/apps/webapp/app/components/primitives/RadioButton.tsx @@ -22,7 +22,7 @@ const variants = { }, "button/small": { button: - "flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent data-[state=checked]:bg-charcoal-850", + "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border hover:data-[state=checked]:border-charcoal-600 border-charcoal-600 hover:border-charcoal-550 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%]", label: "text-sm text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-0", @@ -38,7 +38,7 @@ const variants = { }, description: { button: - "w-full p-2.5 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-600 border rounded-sm", + "w-full p-2.5 hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%] transition data-[disabled]:opacity-70 hover:border-charcoal-550 border-charcoal-600 hover:data-[state=checked]:border-charcoal-600 border rounded-md", label: "text-text-bright font-semibold -mt-1 text-left text-sm", description: "text-text-dimmed -mt-0 text-left", inputPosition: "mt-0", From e272ce3117db9b26646569944ad588f0279afaba Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 9 Jul 2025 17:01:13 +0100 Subject: [PATCH 113/212] Style improvements to the radio buttons From 8800e2dc85f729920ead57da8ec0c01a81a6692a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 9 Jul 2025 17:01:13 +0100 Subject: [PATCH 114/212] Style improvements to the radio buttons From b24b7deb4768b8571901ff8c7ad9ec8fe4d51fa3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 15:06:27 +0100 Subject: [PATCH 115/212] Added an option action completion email --- ...ectParam.env.$envParam.runs.bulkaction.tsx | 74 ++++++++++----- .../v3/services/bulk/BulkActionV2.server.ts | 93 +++++++++++++++++-- .../services/bulk/createBulkAction.server.ts | 4 +- .../migration.sql | 6 ++ .../database/prisma/schema.prisma | 7 ++ .../emails/emails/bulk-action-complete.tsx | 65 +++++++++++++ internal-packages/emails/src/index.tsx | 10 ++ 7 files changed, 222 insertions(+), 37 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250710105648_bulk_action_notification_type/migration.sql create mode 100644 internal-packages/emails/emails/bulk-action-complete.tsx diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index db91c54726..56b589f367 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -1,10 +1,11 @@ -import { ArrowPathIcon, CheckIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { parse } from "@conform-to/zod"; +import { ArrowPathIcon, CheckIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/24/outline"; import { Form } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; +import { tryCatch } from "@trigger.dev/core"; import { type TaskRunStatus } from "@trigger.dev/database"; import assertNever from "assert-never"; -import { parse } from "@conform-to/zod"; import { useEffect, useState } from "react"; import { typedjson, useTypedFetcher } from "remix-typedjson"; import simplur from "simplur"; @@ -12,6 +13,14 @@ import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { Header2 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; @@ -33,33 +42,22 @@ import { timeFilterRenderValues, } from "~/components/runs/v3/SharedFilters"; import { runStatusTitle } from "~/components/runs/v3/TaskRunStatus"; -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 { useUser } from "~/hooks/useUser"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; -import { clickhouseClient } from "~/services/clickhouseInstance.server"; -import { RunsRepository } from "~/services/runsRepository.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server"; +import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { formatNumber } from "~/utils/numberFormatter"; -import { EnvironmentParamSchema, v3CreateBulkActionPath, v3RunsPath } from "~/utils/pathBuilder"; -import { logger } from "~/services/logger.server"; -import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { findProjectBySlug } from "~/models/project.server"; -import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server"; +import { EnvironmentParamSchema, v3BulkActionPath, v3RunsPath } from "~/utils/pathBuilder"; import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server"; -import { tryCatch } from "@trigger.dev/core"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTrigger, -} from "~/components/primitives/Dialog"; export async function loader({ request, params }: LoaderFunctionArgs) { const userId = await requireUserId(request); @@ -103,11 +101,15 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [ action: BulkActionAction, selectedRunIds: z.array(z.string()), title: z.string().optional(), + failedRedirect: z.string(), + emailNotification: z.preprocess((value) => value === "on", z.boolean()), }), z.object({ mode: z.literal("filter"), action: BulkActionAction, title: z.string().optional(), + failedRedirect: z.string(), + emailNotification: z.preprocess((value) => value === "on", z.boolean()), }), ]); export type CreateBulkActionPayload = z.infer; @@ -130,8 +132,6 @@ export async function action({ params, request }: ActionFunctionArgs) { const formData = await request.formData(); const submission = parse(formData, { schema: CreateBulkActionPayload }); - //todo add success and failure paths in the form - if (!submission.value) { logger.error("Invalid bulk action", { submission, @@ -156,12 +156,24 @@ export async function action({ params, request }: ActionFunctionArgs) { logger.error("Failed to create bulk action", { error, }); - // todo decent error message - return redirectWithErrorMessage("/", request, "Failed to create bulk action"); + + return redirectWithErrorMessage( + submission.value.failedRedirect, + request, + `Failed to create bulk action: ${error.message}` + ); } - //todo redirect to the bulk action page - return redirectWithSuccessMessage("/", request, "Bulk action created"); + return redirectWithSuccessMessage( + v3BulkActionPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: result.bulkActionId } + ), + request, + "Bulk action started" + ); } export function CreateBulkActionInspector({ @@ -178,6 +190,7 @@ export function CreateBulkActionInspector({ const fetcher = useTypedFetcher(); const { value, replace } = useSearchParams(); const location = useOptimisticLocation(); + const user = useUser(); useEffect(() => { fetcher.load( @@ -204,6 +217,7 @@ export function CreateBulkActionInspector({ className="h-full" id="bulk-action-form" > +
Create a bulk action @@ -344,6 +358,16 @@ export function CreateBulkActionInspector({ ? "All matching runs will be replayed." : "Runs that are still in progress will be canceled. If a run finishes before this bulk action processes it, it can’t be canceled."} +
+ +
+ ) : null}
@@ -186,6 +186,7 @@ export default function Page() { bulkId: bulkAction.friendlyId, })} LeadingIcon={RunsIcon} + leadingIconClassName="text-indigo-500" > View runs diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 56b589f367..cb3f9caca2 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -362,7 +362,7 @@ export function CreateBulkActionInspector({ - Bulk action {bulkActionId} completed + Bulk action {bulkActionId} finished. - Bulk action {bulkActionId} finished. - You bulk action finished processing: + Bulk action finished + Here's a summary of your bulk action: - - Successfully {pastTense(type)}: {totalCount} runs. - - - Failed to {type.toLocaleLowerCase()}: {failureCount} runs. - + + + + + + + + Trigger.dev
@@ -55,6 +110,19 @@ export default function Email(props: BulkActionCompletedEmailProps) { ); } +function Property({ label, value }: { label: string; value: string | number }) { + return ( + + + {label} + + + {value} + + + ); +} + function pastTense(type: "CANCEL" | "REPLAY") { switch (type) { case "CANCEL": diff --git a/internal-packages/emails/emails/components/styles.ts b/internal-packages/emails/emails/components/styles.ts index 6c07873f96..7e7db86621 100644 --- a/internal-packages/emails/emails/components/styles.ts +++ b/internal-packages/emails/emails/components/styles.ts @@ -29,6 +29,11 @@ export const hr = { margin: "20px 0", }; +export const sans = { + fontFamily: + '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', +}; + export const paragraph = { color: "#878C99", fontFamily: @@ -66,6 +71,10 @@ export const bullets = { margin: "0", }; +export const grey = { + color: "#878C99", +}; + export const anchor = { color: "#826DFF", fontFamily: diff --git a/internal-packages/emails/src/index.tsx b/internal-packages/emails/src/index.tsx index ec9790e396..e43e60f3f4 100644 --- a/internal-packages/emails/src/index.tsx +++ b/internal-packages/emails/src/index.tsx @@ -140,7 +140,7 @@ export class EmailClient { } case "bulk-action-completed": { return { - subject: `Bulk action ${data.bulkActionId} completed`, + subject: `Bulk action finished`, component: , }; } From 5beb2a7c479c06e983525db4936771b58de535b2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 15:48:29 +0100 Subject: [PATCH 122/212] Nicer completed email From 1beed6f59c69403761b890fb3ecf071ea1ff66e7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 15:48:29 +0100 Subject: [PATCH 123/212] Nicer completed email From d962ed3b577c1db9f65b653e2e0d96a533e56562 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 16:14:43 +0100 Subject: [PATCH 124/212] Don't open the bulk action panel if there are no runs --- .../route.tsx | 207 +++++++++--------- 1 file changed, 98 insertions(+), 109 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index c7919d7239..bad66e55a9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -1,11 +1,7 @@ -import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid"; import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid"; -import { Form, type MetaFunction, useNavigation } from "@remix-run/react"; +import { type MetaFunction, useNavigation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { IconCircleX } from "@tabler/icons-react"; -import { AnimatePresence, motion } from "framer-motion"; -import { ListChecks, ListX } from "lucide-react"; -import { Suspense, useState } from "react"; +import { Suspense } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; @@ -13,16 +9,8 @@ import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence" import { StepContentContainer } from "~/components/StepContentContainer"; import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTrigger, -} from "~/components/primitives/Dialog"; -import { Header1, Header2 } from "~/components/primitives/Headers"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { Header1 } from "~/components/primitives/Headers"; import { InfoPanel } from "~/components/primitives/InfoPanel"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -31,11 +19,8 @@ import { ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; -import { - SelectedItemsProvider, - useSelectedItems, -} from "~/components/primitives/SelectedItemsProvider"; -import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner"; +import { SelectedItemsProvider } from "~/components/primitives/SelectedItemsProvider"; +import { Spinner } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; import { RunsFilters } from "~/components/runs/v3/RunFilters"; @@ -62,7 +47,6 @@ import { EnvironmentParamSchema, v3CreateBulkActionPath, v3ProjectPath, - v3RunsPath, v3TestPath, } from "~/utils/pathBuilder"; import { ListPagination } from "../../components/ListPagination"; @@ -130,8 +114,6 @@ export default function Page() { const environment = useEnvironment(); const searchParams = useSearchParams(); - const isShowingBulkActionInspector = searchParams.has("bulkInspector"); - return ( <> @@ -155,97 +137,104 @@ 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) => { + const isShowingBulkActionInspector = + searchParams.has("bulkInspector") && list.hasAnyRuns; + return ( + + +
+ <> + {list.runs.length === 0 && !list.hasAnyRuns ? ( + list.possibleTasks.length === 0 ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( -
-
- -
- {!isShowingBulkActionInspector && ( - 0 ? "selected" : undefined - )} - LeadingIcon={ListCheckedIcon} - className={selectedItems.size > 0 ? "pr-1" : undefined} - > - - Bulk action - {selectedItems.size > 0 && ( - {selectedItems.size} +
+
+ +
+ {!isShowingBulkActionInspector && ( + 0 ? "selected" : undefined )} - - - )} - + LeadingIcon={ListCheckedIcon} + className={selectedItems.size > 0 ? "pr-1" : undefined} + > + + Bulk action + {selectedItems.size > 0 && ( + {selectedItems.size} + )} + + + )} + +
-
- -
- )} + +
+ )} + +
+ + {isShowingBulkActionInspector && ( + <> + + + + )} - - -
-
- {isShowingBulkActionInspector && ( - <> - - - - - - )} -
+ + ); + }} +
+ )} From 8d63cc1ee104224f16dca92d0e78a49344e932f0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 16:14:43 +0100 Subject: [PATCH 125/212] Don't open the bulk action panel if there are no runs From 0e57231758e3e01986a8dace399a2b94f90f1289 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 16:14:43 +0100 Subject: [PATCH 126/212] Don't open the bulk action panel if there are no runs From b66516d1a24fbc3a73b3582e703a1d71093e9af1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 16:42:33 +0100 Subject: [PATCH 127/212] Runs blank state and bulk action accordion --- .../app/components/primitives/Accordion.tsx | 19 +++++++-- .../route.tsx | 11 +++-- ...ectParam.env.$envParam.runs.bulkaction.tsx | 42 ++++++++++++++++++- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/components/primitives/Accordion.tsx b/apps/webapp/app/components/primitives/Accordion.tsx index beb61b2012..0f327e4538 100644 --- a/apps/webapp/app/components/primitives/Accordion.tsx +++ b/apps/webapp/app/components/primitives/Accordion.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { ChevronDown } from "lucide-react"; import { cn } from "~/utils/cn"; +import { Icon, type RenderIcon } from "./Icon"; const Accordion = AccordionPrimitive.Root; @@ -19,20 +20,30 @@ const AccordionItem = React.forwardRef< )); AccordionItem.displayName = "AccordionItem"; +type AccordionTriggerProps = React.ComponentPropsWithoutRef & { + leadingIcon?: RenderIcon; + leadingIconClassName?: string; +}; + const AccordionTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + AccordionTriggerProps +>(({ className, children, leadingIcon, leadingIconClassName, ...props }, ref) => ( svg]:rotate-180", + "flex flex-1 items-center justify-between py-2 pl-2 pr-3 text-sm text-text-bright transition-all group-hover:bg-grid-bright [&[data-state=open]>svg]:rotate-180", className )} {...props} > - {children} +
+ {leadingIcon && ( + + )} +
{children}
+
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index bad66e55a9..211e5dc664 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -139,10 +139,13 @@ export default function Page() { {({ selectedItems }) => ( -
- - Loading runs +
+
+
+
+ + Loading runs +
} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index cb3f9caca2..64f9698454 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -1,5 +1,5 @@ import { parse } from "@conform-to/zod"; -import { ArrowPathIcon, CheckIcon } from "@heroicons/react/20/solid"; +import { ArrowPathIcon, CheckIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/24/outline"; import { Form } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/router"; @@ -11,6 +11,14 @@ import { typedjson, useTypedFetcher } from "remix-typedjson"; import simplur from "simplur"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; +import selectRunsIndividually from "~/assets/images/select-runs-individually.png"; +import selectRunsUsingFilters from "~/assets/images/select-runs-using-filters.png"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "~/components/primitives/Accordion"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; @@ -208,7 +216,6 @@ export function CreateBulkActionInspector({ const impactedCountElement = mode === "selected" ? selectedItems.size : ; - const impactedCount = mode === "selected" ? selectedItems.size : data?.count ?? 0; return (
+
+ + + + How to create a bulk action + + +
+ + Select runs individually using the checkboxes. + +
+ Select runs individually +
+ + Or select runs using the filter menus on this page. + +
+ Select runs using filters +
+ + Then complete the form below and click “Cancel runs” or “Replay runs”. + +
+
+
+
+
{Array.from(selectedItems).map((runId) => { return ; From ff74318cff8d95540571008053a665543d234406 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 16:42:33 +0100 Subject: [PATCH 128/212] Runs blank state and bulk action accordion From 3fa16a109ae05d8797ec070e3aead87c19608715 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 16:42:33 +0100 Subject: [PATCH 129/212] Runs blank state and bulk action accordion From f33516aa37a1e3a922eb11e7c1ba80ec838ab689 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 14:57:56 +0100 Subject: [PATCH 130/212] Updates secondary/small switch style --- apps/webapp/app/components/primitives/Switch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 0a66ea8e30..10fb0bf297 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -40,7 +40,7 @@ const variations = { ), root: cn( small.root, - "group-data-[state=unchecked]:bg-charcoal-800 group-data-[state=unchecked]:group-hover:bg-charcoal-800/50" + "group-data-[state=unchecked]:bg-charcoal-550 group-data-[state=unchecked]:group-hover:bg-charcoal-500" ), thumb: small.thumb, text: cn(small.text, "transition text-text-bright group-disabled:group-hover:text-text-dimmed"), From 117c774c6d86d2831c135c07ffc75c1c9dfea021 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 14:57:56 +0100 Subject: [PATCH 131/212] Updates secondary/small switch style From 66b8a978aeb5b0719ed6915ba0722765b4ad5531 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 14:57:56 +0100 Subject: [PATCH 132/212] Updates secondary/small switch style From 317f4bab495e8cc84e65e0c3783b13949e620f83 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 16:48:11 +0100 Subject: [PATCH 133/212] Pagination buttons no longer split in twain (WIP) --- apps/webapp/app/components/ListPagination.tsx | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/apps/webapp/app/components/ListPagination.tsx b/apps/webapp/app/components/ListPagination.tsx index 0fc1e76d51..bab36445ea 100644 --- a/apps/webapp/app/components/ListPagination.tsx +++ b/apps/webapp/app/components/ListPagination.tsx @@ -16,50 +16,54 @@ export type Direction = z.infer; export function ListPagination({ list, className }: { list: List; className?: string }) { return ( -
+
); } -function NextButton({ cursor }: { cursor?: string }) { - const path = useCursorPath(cursor, "forward"); +function PreviousButton({ cursor }: { cursor?: string }) { + const path = useCursorPath(cursor, "backward"); return ( - !path && e.preventDefault()} - shortcut={{ key: "k" }} - tooltip="Next" - disabled={!path} - /> +
+ !path && e.preventDefault()} + shortcut={{ key: "j" }} + tooltip="Previous" + disabled={!path} + /> +
); } -function PreviousButton({ cursor }: { cursor?: string }) { - const path = useCursorPath(cursor, "backward"); +function NextButton({ cursor }: { cursor?: string }) { + const path = useCursorPath(cursor, "forward"); return ( - !path && e.preventDefault()} - shortcut={{ key: "j" }} - tooltip="Previous" - disabled={!path} - /> +
+ !path && e.preventDefault()} + shortcut={{ key: "k" }} + tooltip="Next" + disabled={!path} + /> +
); } From 48bb7d76c0868d8f71f249dae4623506f12471c5 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 16:48:11 +0100 Subject: [PATCH 134/212] Pagination buttons no longer split in twain (WIP) From 2ae73ac268507cb8131a0824abebf246ee9ed738 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 16:48:11 +0100 Subject: [PATCH 135/212] Pagination buttons no longer split in twain (WIP) From 87a5db6b4f08e6b1544e7cc5bae29852e2309031 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 17:06:15 +0100 Subject: [PATCH 136/212] Aborting working --- .../route.tsx | 62 ++++++++++++++++++- .../v3/services/bulk/BulkActionV2.server.ts | 43 ++++++++++++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index f58338510c..68bce9da12 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -1,5 +1,5 @@ import { ArrowPathIcon, BookOpenIcon } from "@heroicons/react/20/solid"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -37,10 +37,15 @@ import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, + v3BulkActionPath, v3BulkActionsPath, v3CreateBulkActionPath, v3RunsPath, } from "~/utils/pathBuilder"; +import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server"; +import { logger } from "~/services/logger.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { Form } from "@remix-run/react"; const BulkActionParamSchema = EnvironmentParamSchema.extend({ bulkActionParam: z.string(), @@ -85,6 +90,53 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } }; +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam, bulkActionParam } = + BulkActionParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const service = new BulkActionService(); + const [error, result] = await tryCatch(service.abort(bulkActionParam, environment.id)); + + if (error) { + logger.error("Failed to abort bulk action", { + error, + }); + + return redirectWithErrorMessage( + v3BulkActionPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: bulkActionParam } + ), + request, + `Failed to abort bulk action: ${error.message}` + ); + } + + return redirectWithSuccessMessage( + v3BulkActionPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: bulkActionParam } + ), + request, + "Bulk action aborted" + ); +}; + export default function Page() { const { bulkAction } = useTypedLoaderData(); const organization = useOrganization(); @@ -108,8 +160,12 @@ export default function Page() {
- {bulkAction.status !== "PENDING" ? ( - + {bulkAction.status === "PENDING" ? ( + + + ) : null}
diff --git a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts index a9f77cd903..9d1e9b44e5 100644 --- a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts +++ b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts @@ -85,9 +85,10 @@ export class BulkActionService extends BaseService { public async process(bulkActionId: string) { // 1. Get the bulk action group - const group = await this._prisma.bulkActionGroup.findUnique({ + const group = await this._prisma.bulkActionGroup.findFirst({ where: { id: bulkActionId }, select: { + status: true, friendlyId: true, projectId: true, environmentId: true, @@ -130,6 +131,11 @@ export class BulkActionService extends BaseService { throw new Error(`Bulk action group has no environment: ${bulkActionId}`); } + if (group.status === BulkActionStatus.ABORTED) { + logger.log(`Bulk action group already aborted: ${bulkActionId}`); + return; + } + // 2. Parse the params const filters = parseRunListInputOptions({ organizationId: group.project.organizationId, @@ -333,7 +339,6 @@ export class BulkActionService extends BaseService { } // 6. If there are more runs to process, queue the next batch - await commonWorker.enqueue({ id: `processBulkAction-${bulkActionId}`, job: "processBulkAction", @@ -341,6 +346,40 @@ export class BulkActionService extends BaseService { availableAt: new Date(Date.now() + env.BULK_ACTION_BATCH_DELAY_MS), }); } + + public async abort(friendlyId: string, environmentId: string) { + const group = await this._prisma.bulkActionGroup.findFirst({ + where: { friendlyId, environmentId }, + select: { + id: true, + status: true, + }, + }); + + if (!group) { + throw new Error(`Bulk action not found: ${friendlyId}`); + } + + if (group.status === BulkActionStatus.COMPLETED) { + throw new Error(`Bulk action group already completed: ${friendlyId}`); + } + + if (group.status === BulkActionStatus.ABORTED) { + throw new Error(`Bulk action group already aborted: ${friendlyId}`); + } + + //ack the job (this doesn't guarantee it won't run again) + await commonWorker.ack(`processBulkAction-${group.id}`); + + await this._prisma.bulkActionGroup.update({ + where: { id: group.id }, + data: { status: BulkActionStatus.ABORTED }, + }); + + return { + bulkActionId: friendlyId, + }; + } } async function getFilters(payload: CreateBulkActionPayload, request: Request) { From 0d3c5072fec69afab934270363d7ef7ca0d34d08 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 17:06:15 +0100 Subject: [PATCH 137/212] Aborting working From 719fea20d2e07c199edb038012949bd024355af6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 17:06:15 +0100 Subject: [PATCH 138/212] Aborting working From 71b60ac1529635d04809edf49845c63519822b4f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 18:16:29 +0100 Subject: [PATCH 139/212] Bulk action live reloading --- .../app/components/primitives/Tooltip.tsx | 9 +- .../v3/BulkActionPresenter.server.ts | 2 + .../route.tsx | 98 +++++++++++++++- ...uns.bulkaction.$bulkActionParam.stream.tsx | 105 ++++++++++++++++++ .../v3/services/bulk/BulkActionV2.server.ts | 6 +- .../app/v3/services/cancelTaskRun.server.ts | 14 ++- .../run-engine/src/engine/index.ts | 2 +- .../src/engine/systems/runAttemptSystem.ts | 22 +++- 8 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index f5181cedc0..15dd72894a 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -61,6 +61,7 @@ function SimpleTooltip({ disableHoverableContent = false, className, buttonClassName, + buttonStyle, asChild = false, sideOffset, }: { @@ -72,13 +73,19 @@ function SimpleTooltip({ disableHoverableContent?: boolean; className?: string; buttonClassName?: string; + buttonStyle?: React.CSSProperties; asChild?: boolean; sideOffset?: number; }) { return ( - + {button} { + if (disabled || streamedEvents === null) { + return; + } + + revalidation.revalidate(); + }, [streamedEvents, disabled]); + return (
@@ -170,7 +195,15 @@ export default function Page() {
-
+
+ +
+
ID @@ -250,3 +283,64 @@ export default function Page() {
); } + +type MeterProps = { + type: BulkActionType; + successCount: number; + failureCount: number; + totalCount: number; +}; + +function Meter({ type, successCount, failureCount, totalCount }: MeterProps) { + const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100; + const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100; + + return ( +
+
+ Runs + + {formatNumber(successCount + failureCount)}/{formatNumber(totalCount)} + +
+
+ } + /> + } + /> +
+
+
+
+ + {formatNumber(successCount)} {typeText(type)} successfully + +
+
+
+ + {formatNumber(failureCount)} {typeText(type)} failed{" "} + {type === BulkActionType.CANCEL ? " (already finished)" : ""} + +
+
+
+ ); +} + +function typeText(type: BulkActionType) { + switch (type) { + case BulkActionType.CANCEL: + return "canceled"; + case BulkActionType.REPLAY: + return "replayed"; + } +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx new file mode 100644 index 0000000000..3eb4a1b208 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx @@ -0,0 +1,105 @@ +import { BulkActionStatus } from "@trigger.dev/database"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { env } from "~/env.server"; +import { devPresence } from "~/presenters/v3/DevPresence.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, ProjectParamSchema } from "~/utils/pathBuilder"; +import { createSSELoader, type SendFunction } from "~/utils/sse"; + +const Params = EnvironmentParamSchema.extend({ + bulkActionParam: z.string(), +}); + +export const loader = createSSELoader({ + timeout: env.DEV_PRESENCE_SSE_TIMEOUT, + interval: env.DEV_PRESENCE_POLL_MS, + debug: false, + handler: async ({ id, controller, debug, request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam, bulkActionParam } = Params.parse(params); + + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { + id: envParam, + project: { + slug: projectParam, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }, + }); + + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const getBulkActionProgress = async (send: SendFunction) => { + try { + const bulkAction = await $replica.bulkActionGroup.findFirst({ + select: { + status: true, + successCount: true, + failureCount: true, + }, + where: { + friendlyId: bulkActionParam, + environmentId: environment.id, + }, + }); + + send({ + event: "progress", + data: JSON.stringify({ + status: bulkAction?.status, + successCount: bulkAction?.successCount, + failureCount: bulkAction?.failureCount, + }), + }); + + return bulkAction; + } catch (error) { + // Handle the case where the controller is closed + logger.debug("Failed to send bulk action progress data, stream might be closed", { error }); + return null; + } + }; + + return { + beforeStream: async () => { + logger.debug("Start dev presence listening SSE session", { + environmentId: environment.id, + }); + }, + initStream: async ({ send }) => { + const bulkAction = await getBulkActionProgress(send); + + send({ event: "time", data: new Date().toISOString() }); + + if (bulkAction?.status !== BulkActionStatus.PENDING) { + return false; + } + + return true; + }, + iterator: async ({ send, date }) => { + const bulkAction = await getBulkActionProgress(send); + + if (bulkAction?.status !== BulkActionStatus.PENDING) { + return false; + } + + return true; + }, + cleanup: async ({ send }) => { + await getBulkActionProgress(send); + }, + }; + }, +}); diff --git a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts index 9d1e9b44e5..95dcbbb500 100644 --- a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts +++ b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts @@ -211,7 +211,11 @@ export class BulkActionService extends BaseService { failureCount++; } else { - successCount++; + if (!result || result.alreadyFinished) { + failureCount++; + } else { + successCount++; + } } } diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index a7cfbf8027..ef2bc5ee1c 100644 --- a/apps/webapp/app/v3/services/cancelTaskRun.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRun.server.ts @@ -15,6 +15,7 @@ export type CancelTaskRunServiceOptions = { type CancelTaskRunServiceResult = { id: string; + alreadyFinished: boolean; }; export type CancelableTaskRun = Pick< @@ -39,14 +40,22 @@ export class CancelTaskRunService extends BaseService { options?: CancelTaskRunServiceOptions ): Promise { const service = new CancelTaskRunServiceV1(this._prisma); - return await service.call(taskRun, options); + const result = await service.call(taskRun, options); + + if (!result) { + return; + } + + return { + id: result.id, + alreadyFinished: false, + }; } private async callV2( taskRun: CancelableTaskRun, options?: CancelTaskRunServiceOptions ): Promise { - //todo bulkActionId const result = await engine.cancelRun({ runId: taskRun.id, completedAt: options?.cancelledAt, @@ -57,6 +66,7 @@ export class CancelTaskRunService extends BaseService { return { id: result.run.id, + alreadyFinished: result.alreadyFinished, }; } } diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 37b8f09412..3a97bd2eb1 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -714,7 +714,7 @@ export class RunEngine { finalizeRun?: boolean; bulkActionId?: string; tx?: PrismaClientOrTransaction; - }): Promise { + }): Promise { return this.runAttemptSystem.cancelRun({ runId, workerId, diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 13f1d2cdbc..25d68df323 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -973,7 +973,7 @@ export class RunAttemptSystem { finalizeRun?: boolean; bulkActionId?: string; tx?: PrismaClientOrTransaction; - }): Promise { + }): Promise { const prisma = tx ?? this.$.prisma; reason = reason ?? "Cancelled by user"; @@ -993,7 +993,10 @@ export class RunAttemptSystem { }, }); } - return executionResultFromSnapshot(latestSnapshot); + return { + alreadyFinished: true, + ...executionResultFromSnapshot(latestSnapshot), + }; } //is pending cancellation and we're not finalizing, alert the worker again @@ -1003,7 +1006,10 @@ export class RunAttemptSystem { snapshot: latestSnapshot, eventBus: this.$.eventBus, }); - return executionResultFromSnapshot(latestSnapshot); + return { + alreadyFinished: false, + ...executionResultFromSnapshot(latestSnapshot), + }; } //set the run to cancelled immediately @@ -1090,7 +1096,10 @@ export class RunAttemptSystem { snapshot: newSnapshot, eventBus: this.$.eventBus, }); - return executionResultFromSnapshot(newSnapshot); + return { + alreadyFinished: false, + ...executionResultFromSnapshot(newSnapshot), + }; } //not executing, so we will actually finish the run @@ -1157,7 +1166,10 @@ export class RunAttemptSystem { } } - return executionResultFromSnapshot(newSnapshot); + return { + alreadyFinished: false, + ...executionResultFromSnapshot(newSnapshot), + }; }); }); } From 6010fd64ab2120441b86f7da967d02d5010f34ee Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 18:16:29 +0100 Subject: [PATCH 140/212] Bulk action live reloading From 37a95faa61e22c10b41978155ae50eaa5bb81416 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 18:16:29 +0100 Subject: [PATCH 141/212] Bulk action live reloading From fb0684a2a761eac9c9751ebdaf3035f00a72ab5b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 18:29:35 +0100 Subject: [PATCH 142/212] ListPagination works correctly in all states --- apps/webapp/app/components/ListPagination.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/ListPagination.tsx b/apps/webapp/app/components/ListPagination.tsx index bab36445ea..6e26330677 100644 --- a/apps/webapp/app/components/ListPagination.tsx +++ b/apps/webapp/app/components/ListPagination.tsx @@ -15,10 +15,18 @@ export const DirectionSchema = z.union([z.literal("forward"), z.literal("backwar export type Direction = z.infer; export function ListPagination({ list, className }: { list: List; className?: string }) { + const bothDisabled = !list.pagination.previous && !list.pagination.next; + return (
+
); } @@ -27,14 +35,14 @@ function PreviousButton({ cursor }: { cursor?: string }) { const path = useCursorPath(cursor, "backward"); return ( -
+
!path && e.preventDefault()} shortcut={{ key: "j" }} @@ -49,14 +57,14 @@ function NextButton({ cursor }: { cursor?: string }) { const path = useCursorPath(cursor, "forward"); return ( -
+
!path && e.preventDefault()} shortcut={{ key: "k" }} From e69edadf7ac2d421091770f36a72652ac7c2df37 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 18:29:35 +0100 Subject: [PATCH 143/212] ListPagination works correctly in all states From 3b54687bb1f37f046e2275f7ac44648b55e6978c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 10 Jul 2025 18:29:35 +0100 Subject: [PATCH 144/212] ListPagination works correctly in all states From ba186d9c56e8a5b3142cda8c586073d60cfb181a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 19:07:55 +0100 Subject: [PATCH 145/212] Run page, show friendlyId instead of number --- .../route.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 9e9145be9a..a27e67f630 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -96,6 +96,7 @@ import { import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { CopyableText } from "~/components/primitives/CopyableText"; const resizableSettings = { parent: { @@ -199,7 +200,7 @@ export default function Page() { to: v3RunsPath(organization, project, environment), text: "Runs", }} - title={`Run #${run.number}`} + title={} /> {environment.type === "DEVELOPMENT" && } From b154a4c3c041568e2746c1fdb74a45595d7eae22 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 19:07:55 +0100 Subject: [PATCH 146/212] Run page, show friendlyId instead of number From 698fbe190f6dc532962a59996bb8f504c2ee2ec3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 10 Jul 2025 19:07:55 +0100 Subject: [PATCH 147/212] Run page, show friendlyId instead of number From 998254d6117c397bd4c4f519a9de528339a22dbf Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 10:00:44 +0100 Subject: [PATCH 148/212] Bulk action help open by default if you have none --- .../route.tsx | 1 + ...cts.$projectParam.env.$envParam.runs.bulkaction.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 211e5dc664..666bb05c82 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -229,6 +229,7 @@ export default function Page() { 0} /> diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 64f9698454..d784c98f5c 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -187,9 +187,11 @@ export async function action({ params, request }: ActionFunctionArgs) { export function CreateBulkActionInspector({ filters, selectedItems, + hasBulkActions, }: { filters: TaskRunListSearchFilters; selectedItems: Set; + hasBulkActions: boolean; }) { const [isDialogOpen, setIsDialogOpen] = useState(false); const organization = useOrganization(); @@ -243,8 +245,12 @@ export function CreateBulkActionInspector({
- - + + Date: Fri, 11 Jul 2025 10:00:44 +0100 Subject: [PATCH 149/212] Bulk action help open by default if you have none From 67e209e8cb5297ea297b2e5900c6a8b46e351f69 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 10:00:44 +0100 Subject: [PATCH 150/212] Bulk action help open by default if you have none From af57ddf692a70ba20c6ac2edb76b0a20e7b96ddc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 11:18:52 +0100 Subject: [PATCH 151/212] Extra status filtering step because of replication delay --- apps/webapp/app/services/runsRepository.server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/services/runsRepository.server.ts b/apps/webapp/app/services/runsRepository.server.ts index f2b122740c..b9a2ad521c 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository.server.ts @@ -129,7 +129,7 @@ export class RunsRepository { ? runIds.slice(1, options.page.size + 1) : runIds.slice(0, options.page.size); - const runs = await this.options.prisma.taskRun.findMany({ + let runs = await this.options.prisma.taskRun.findMany({ where: { id: { in: runIdsToReturn, @@ -168,6 +168,11 @@ export class RunsRepository { }, }); + // ClickHouse is slightly delayed, so we're going to do in-memory status filtering too + if (options.statuses && options.statuses.length > 0) { + runs = runs.filter((run) => options.statuses!.includes(run.status)); + } + return { runs, pagination: { From d8144080a2f1893e128722ab57ec23bbf715a21f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 11:18:52 +0100 Subject: [PATCH 152/212] Extra status filtering step because of replication delay From 41fe0c8fdfed4726ef13763e553d56ffca15bf55 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 11:18:52 +0100 Subject: [PATCH 153/212] Extra status filtering step because of replication delay From e8a6618f0425646ba9e374c66dd6d238fa7564fe Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 11:19:24 +0100 Subject: [PATCH 154/212] Wider bulk action onboarding --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index b6ebb32e27..94db7362e5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -145,7 +145,7 @@ export default function Page() { {bulkActions.length === 0 ? ( - + ) : ( From 7c832e163595a6ea9883ad8fb027ce60a5da9675 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 11:19:24 +0100 Subject: [PATCH 155/212] Wider bulk action onboarding From cc0dffc36b7ebcc40e288595863ee01e5c7ce7c0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 11:19:24 +0100 Subject: [PATCH 156/212] Wider bulk action onboarding From 0fd224fe7451cbed7113f5842127c9679fef8bdf Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 11:25:33 +0100 Subject: [PATCH 157/212] More sensible widths on the bulk action side panel --- .../route.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 666bb05c82..2b02b426e3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -225,7 +225,12 @@ export default function Page() { {isShowingBulkActionInspector && ( <> - + Date: Fri, 11 Jul 2025 11:25:33 +0100 Subject: [PATCH 158/212] More sensible widths on the bulk action side panel From fae499c71103d500ca6f6917c1aa2c573e9ae70c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 11:25:33 +0100 Subject: [PATCH 159/212] More sensible widths on the bulk action side panel From 3a8ec135189bbb51d5be22a257cbf54af4b60052 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 11:00:19 +0100 Subject: [PATCH 160/212] Border color tweak to the RadioButton --- apps/webapp/app/components/primitives/RadioButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx index d605d0883e..6ea04f3b56 100644 --- a/apps/webapp/app/components/primitives/RadioButton.tsx +++ b/apps/webapp/app/components/primitives/RadioButton.tsx @@ -22,7 +22,7 @@ const variants = { }, "button/small": { button: - "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border hover:data-[state=checked]:border-charcoal-600 border-charcoal-600 hover:border-charcoal-550 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%]", + "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border hover:data-[state=checked]:border-charcoal-600 border-charcoal-650 hover:border-charcoal-600 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%]", label: "text-sm text-text-bright select-none", description: "text-text-dimmed", inputPosition: "mt-0", @@ -38,7 +38,7 @@ const variants = { }, description: { button: - "w-full p-2.5 hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%] transition data-[disabled]:opacity-70 hover:border-charcoal-550 border-charcoal-600 hover:data-[state=checked]:border-charcoal-600 border rounded-md", + "w-full p-2.5 hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%] transition data-[disabled]:opacity-70 hover:border-charcoal-600 border-charcoal-650 hover:data-[state=checked]:border-charcoal-600 border rounded-md", label: "text-text-bright font-semibold -mt-1 text-left text-sm", description: "text-text-dimmed -mt-0 text-left", inputPosition: "mt-0", From 0e1cdaa8e1808471d6ae759e8f939497f55bb255 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 11:00:19 +0100 Subject: [PATCH 161/212] Border color tweak to the RadioButton From 3061728969f1256fe4db6d35e07ae06776f48ccd Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 11:00:19 +0100 Subject: [PATCH 162/212] Border color tweak to the RadioButton From e533e347cdc69b69e89d6a3b3912ac0cab0b5b30 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 11:20:14 +0100 Subject: [PATCH 163/212] Improved the accordion component hover states --- apps/webapp/app/components/primitives/Accordion.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/Accordion.tsx b/apps/webapp/app/components/primitives/Accordion.tsx index 0f327e4538..c39a245b98 100644 --- a/apps/webapp/app/components/primitives/Accordion.tsx +++ b/apps/webapp/app/components/primitives/Accordion.tsx @@ -14,7 +14,7 @@ const AccordionItem = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -33,7 +33,7 @@ const AccordionTrigger = React.forwardRef< svg]:rotate-180", + "flex flex-1 items-center justify-between py-2 pl-2 pr-3 text-sm text-text-bright transition group-hover:border-grid-bright hover:bg-grid-dimmed [&[data-state=open]>svg]:rotate-180", className )} {...props} From 320d93c57f9f4a38c123d51d9543ce3ebf51da87 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 11:20:14 +0100 Subject: [PATCH 164/212] Improved the accordion component hover states From a58f66ee6ff8e9e3f04f43730b8a9928c812ed0c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 11:20:14 +0100 Subject: [PATCH 165/212] Improved the accordion component hover states From 5b3d725a443d525c4a9d73efdd358fc2eae2ef8b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 11:22:08 +0100 Subject: [PATCH 166/212] Updates the bulk action blank state images to the latest UI --- .../assets/images/open-bulk-actions-panel.png | Bin 4977 -> 2910 bytes .../images/select-runs-individually.png | Bin 9853 -> 6230 bytes .../images/select-runs-using-filters.png | Bin 9874 -> 6450 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/webapp/app/assets/images/open-bulk-actions-panel.png b/apps/webapp/app/assets/images/open-bulk-actions-panel.png index 0999ac63176ba99e332a5e032c9c0210c67aa9ab..a1b48f386465f0fd7f3205aae68e51237ba3a32e 100644 GIT binary patch delta 2889 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 delta 4972 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 diff --git a/apps/webapp/app/assets/images/select-runs-using-filters.png b/apps/webapp/app/assets/images/select-runs-using-filters.png index 7679fe8fa26c75f738244bcecb09608c635c3c45..78ce487d0fcd2d43964ce0895c2b8444fa96f45a 100644 GIT binary patch literal 6450 zcmXw;WmptUw17csX{5Ws1(xpa?ohg=OLl3H?vw`UMiE#_x6RgSieW*QJl&M0JJFTE}IF8$S(f|s10+r~2!7qxE>syQ_13P#4) zkQmlNO#6BI*Oa&xR5wB$vk4A>eKNB-!QyW8yIvC(sT|V*tm<$bmcGrTQ>0B) zpikn4Q zjf{+jW@l5|+S(OQ3j>C>OHDJ(Ifz}=fWknb+wU#!XBo9OqwwBEiV7zw(BnpJtH_ki zpoYOV_MOVX^KlV9Gh2uvE#HTS`)X^ozU{qA$bjW6`Le!cQ#9IZThU&T#qT52Gs?|< zm@6hCi_@A?d==@l3ydJbA?~A5O+WYf&NopFWKWM)`!@kKBoUEONzVCM)tRiJY&~s5 z>DK{i69vdhmGJykXOlB=9UO{llmBUQsi~_QW18smpIUuGuCoS{tE)Lu(!E0?BUx8I zu$e?XZ9Pp*HE(Z0o%ioc3`rKXTbz=MHjvWA!!OX{8!_J zazP-FqLr0wYglhj&(QpQ0<*xm(Rh#MCix)ep5Jf2QIu{A7y0R=SPki9J49tPicuXa z@Df%ytmkq=@Vsr#EnH|1_;budt=!gc#(Ix^IykxP$=L5bq5Fb7dOsJgmtGiA92spMK|$F@2E zkw}>VTR!)!WQ1f1YoE;`mRcNn2=3Uz9*eyI-xuaYH|l{nGI=q_>nEs%Y#n5N?5iwE z1oq6qY=z=xwwd($*%{yh8-Sk|6DOOJ3`w!y)~Zm8((y=2?xCap`dCz!Y%^#EJ_k_8 z5ZT&xsWdRsz27}8-mGUI{HQ`FzqPd`?ds}^zu4NSVL!K?&7ZEQs#wsr^~S`5w9=i`E~_K!qEmc-dBn8Uz-cb_?(OX`2UTg{CuVP zaFwiH4lR##?mXu6F+aCpW?o(>rojF5{b zmSk^#!fUSwAYQxYQnYyrg#ysfunIQllJro0w3LR`TIVzQoS4U& ze8Mv@;O`NeFGuSprk|hwep60oyB8t$Oae;SuB0 zpBx{$biB>`dwr~|IB%kiut28wxEbSCE5W%TSe+@DOfIXZSKc(A+~0Wn@q=3(Z#wpQ zWl7cqD_y{8=dDTqpCU^Ysr&ZkTJ%=^y2Tlm(&=9DM7j+B`<+T=@wC)1Rx8h~IDV~w zlRuY4p!{~<+oaA;anZXj1Rj?)k#kFQuQZ_V)!vjfEdO_xD|!Oh%uj@Yk&#c^gNBC2 z;z{4!TstfnhoTLu;jrTE8f)By=@z`Zbke+sM`yXjP};xZhs*cGXu)Q4o}JC4%eTMP>%Zl5dLsVGcjgv#fwgn?Rn z7&VT30x3gBg3ajASV@D!Jl}l)JUqPHR2UIjL3%&UkJErVNiPb~d%*|{5-D$QZ=Of0 zm;5awsz0A^DF}#&`0t-f(v75iZ;$b$$V0=zrZS4@Y3^p*Y;s~ySXvIh@fvX-$l%G^ zpvG@Kj)>n!9i~}T(n6e;du7DL)}#_`hkix1Q7x763fYebyZz4Kx+ zJm~1?czmki+1X^0qAqy+aadJE!fieId;6pLkAhP+p9@8-EDGEmD(^eqo zsDPKOfdne`nKE^vUt+MA>;1C4Y0o20k%4e}DOis z4vsH{g@v~K5i1%(CfoedaUM==(^!Wq z^742|_Dkdd7J@BaOdygbc7JE7d9x8giF=K-buE1&O$F&YfaL`c!Zdcr8rN_?6At5V z;ZQq1A3-KG4Gg}H{X|+^l(&89NU@#>@@(nv>w}!Mpp(R)Hydm9E%A@sXlJcYm8sX; za)T~jSf26+J>S&x9$<1?ZiouKDaLy}A}M{I!}{a6$Uc+*B4qOmCYQ}>q?WJ&?jlcJ zK)BNALnXYtW_bBc9A6FFe3_7!N7Y;_8_dzI>KLZ0qw{HNU=k0VsJ3C+IHfZ*>|xdO zkE*6o5Y$2qY3~f}+_6Etr@%mOfA+`S?syJMN=iFqFIMbmOv-GNghqk2`_l4ELUES} zA5idw6EN@+RvR+wjWkbz$czjWk#Q*0+bhg|Ba$6O{5S!Xi?PpEav7ph}!)Ws2$-V_)xSxN*pq{BzHL_~NR$a2OVK*qNg$4U?% zPWj84+Ble z9SDVbwu}sML?+S-3c%ay7(rZIm19{wTmExlR;TJQu3^EFAEX=_o%c6}SXOF9HhJFR zNy%kUm<}}t1s&{^P)w6O^wiSjGzf(XKe3Fgx!t^8`uyo`M*T`X{v_Yox#iwk^Cnf- zv_w#(!zri+i;gn-o zsBGdJ7miJ(X8QR$RiHCA3>CC{gGhrFh_xJpG&wyXPDFwHK}>|N?=L(^66gxx^@aJr zJb6ajjF_J+h|6AZ>9y==J|ZswN(DUqgpWi>NAeCfT%rQ zcTO7TS1P4S1`W8B5t%*uuBj>bGihUoVY~tRR@|Y}Qh>>?m4e^$rVAI;;J<4B z)?Y^|hId8x?{$pkdk=>GCXaRwkX66NOJ*o*@5G8#>Ge3qJ$tQF&PL8&>No~PJ)Mr(<&>+pC zTHgsLM=x52paN5))YW%GvuuHOKc*S}QtP)n;}M`LvXnNQ4;laDqytWYK@ z8BWJ2mw&qGjxE~3OHrl`!;Tb>6~84IS_bweX$cV6T`?%9@jPSp3m2J(GoDOLM~AM5 zbcLi^pJt0ft&$RE$Kj#OBl_O@mWPX)HWdQ$9J$i@&mbetMfeR)L!H!*8V-ZZlXLAU z;L3J?Uj$3|dyddSb7CMK7S57TUg~Ni`EKp*-jrk2e8H6n%#B)uLlG4Zx?VE2%Ry9H zy+!^2i-~!Chg6kp3J(g30n=atrU*Do7dw2xP;GD*ig(+F(AncIEkVQMT|e0GO!B|i?WyhK?HIo-mG8dzOM!#_o|%vM z5;_fA0~5?)?gs(k8H7A77c#g~KnhKM&8>yEGX#Vni>(cIJ7hN8u^ zqM6y3j893)kk}=%(7A!Qyg?H=PMDB(Xye9dwdm=xftI$%?2GA*(pl`>dd6lA&}uf& zPx4?Z7@Z9V8Rg(*NH%5ke!j7On2bEmg5=C897Otg7`4lz-==SBC$T3mm$O+WZITU-_vkVoH`^^=SUO7tr`qo+k< z4UY*$z<@?lE#a)?w&t)qoQ)p)EE6Ag>`_CnFYh31-}zy09d}kik_%Xe2emqc{j>ok z+mGQ`6iVsF9+??>f#U++)AZ&@8@_}3#)DPjRY(kt4)bh2x9ztwTiv)3vT^anNb1fC zBX(`9v+zQH6rS^%*pYxjD}VAZ>r_y%DM27v6@@^nXC6=MP#kS-ZKvE`(6A&b>SiVm zVZ)Ul4Y~3HOZE@~`UDXLg{n*2dANp$sLvISU^_e6MJmqsL1Dk4+X!+5>lbs25p_pa z+C{No6#P=H&VgLJl6JekU4!oxY;>p2bP6mHdWo1vnyR_ZV`I;oVA1x@QuI{a{coRc z4?i;7w@ebvvEfJWgp!0>;K)^cYskS4?GE%Ao8w@mLLPvcOWXBFQ@x+)^;#Od#*%*H z%?HSx=)5abbjJIY(uQ&1A}r6u(G2hwz6$U{<}jIjN2BNSDL~SD+hS_GN!r~o@%%%%;ri;-#^B;bp!P=)$(}CM$O5!{>pd`-CDLYHDJ+6j zZ%;&(P}_pWGql~6&)3je?D=iwA@R!RnM;8x{*Od_4ceM z!JyOSQ{o6A;zAKLujd_)iK}W3$R{6QiXE50Qtq`34g3}R$0(lhap-9bsrB_~9tGZe zx2J3Ggsw54f$Csv$^oLK^@)50S!>YNqcRZSuDq|Wp7z&!$8-=ea=g}HyuCY_`_FHB zaQ!okZ&K^EjA+VjeufiGC6I9{s?p|N9Ql_Jzkt*l@}%PEYv|J*scrq6MS`uQ^n^ee z`)Fr&_G2h%M(X#NC}j@IjS^9d1yW`<&Llz*QRcFN%$i9)w*+*nJ5J8&wj#XK6b}7s z=}elTHph@#2dkz25!xM?KvY;b=fMoxM=O%5f0K8PeksvBpH_*&BCc4wkxb(WGeY zM|*l=Adboo>0Ch2&v;4s4xD-eU!dWO|)XkugtfQpH|9n$MG7WjWe zUAirHZuM#B$=B1LaYZn>)jz1UtgQQ2Sc1R&$ z=FhF3h6e?ZbMWmMaeO0+*;whEliq(?2(C&DZ8WGE`+er*+}ZgKe!3IxXBB&aYjFc?aTvf3~(uu1>stjLJ}`l!sstbYTFhoYe;3=A6H ze+?EUHy`kC6V_8(K^mrZn(XA?0>NHNO$r9)M>5)r>IVP zPs-BOg-;eaMlHZJdHr8gKC@XuCSIpWtq{=z@?HK!{IbGm_%Sl8#8}`pUm2X5Di+)C zpeg`xQPECI-}2qihw^p|4Sn!mTGLxzTKt)vosILZBK$wmW{uCDp0w{_FrV+QHVn_* zz7`awXhxkgc*Bs%d)t-^qI*aDbN*+4o~^8+JUX&0u$2&`ce{+T!ca)v^4|BDZz!sz zr>FdXYA0nCEiG9ao12}{vA)dHGZT+nUy}IqEP+ZuuK&xSES*PJPY3+<2OyMmmfn^t z&-VaoYNa?X`~L(ERAIxzE8JB%om`s(vNQhA`)~TH3q#%ziG7+sAjylwgX8xC;h06= z$S9>u_ehgBMH7Gcy?p6)?ubE+Ucq~fYzL@`Bm9zmYI*pW4Eg?0CXyscK}ri~>F)0S zrthuSOkd1-C1OJD zMkPtLbF$Vz#Z>%}X|xq$9pf_+xkPuhzAX{v??ND@cN=YyvcvZBQa{7C?d?Q9?_Ks6 zIfyTheGz$m%i+5Q^mi#@I9uM7vYD9~nNXW^bP7KQvY!I7*p?yjx4Ys0qzL0-{on_x z{A#GM`e=pUjkEt{XRK2+{YwEu7^CVJ!-|bcK+8MAfC%eRvpz)=VP%DG`8Z4N5dS9N z)k|?XwMAxH*q7Z2yKugCj)LHq9t7q*8;JvX(gL!_Fg8ZEeeu zA=%mY8i)(S&)_@+!OkZT{>qdJ+gLL84;9%*P=+6g(nr0i083x~8Ce$;o z0#Ue*1~g7o(cLF9(j0bqmKttNZ}um?qyyWAlt49MUHfzJ?|F3YN1xp(w)mD)(ocWg zj^X|Va4@mLiJ~-rUL?(>Cnh5&*FIXyiVQPiAsQbaFT3?iNRVGp@S$T19-5Vrfov`< zEp3#EI9JroOM`Ww0k zw~GA53y4>w@Yz{eBZYm==$4sTCbPZTnwp|!Fj8{PYKAPj6#V=YZuX^d{VXg+Tfy~V zs5hQ``)4O(^3|2YhWj!=EoxE;ERMG)0i%c7zxg}PUS7T|`&RnIx3x+M+5;7a-^WaV zanjFIkSj}#G}xA6E0gV6_m0bknRYf|gJ;@QW2jwRy*Y~RJNNe&t6 z=+b?WxZC{sSAk4snVFgOK#bCUnV57&>r?3b!U6mUY<_q9O>ts?5)kL{;ps>JtxMCw z5OBx4^+L?h2aRsjV~L3G-e$2vYmY8tFpOGBQ8DG(UG-x(ZDw}%r{0j)HbHVl1rV7d z@5}6b)boI)71`ci%-DD(bf%#EwtCxx>_Kt~hw14VDgIMsavxrGDiRVtHej(NAQ{|b zJp$sjn*^M^!7+!Biv{SA2|Ec*AB;kSpYN_khR6I0Mg6r8%D516ju$KO6!5Ws&ZsMZN@lQBXIVFAhQi4M}G6rwzT#xaB%Mh}2*}JYr#9^a2hpuB} zq$&Uc$l$pb%0K$IH(SgQ6VtYNei-ciiv; zR#G?+(zy$tM2J9u)rGG{^#s5RlIp{UibdTI?SMKRalJ3+V#k%^$3`!akv<<>g{*b> zlam@X${sg2^WBhv43Uv%4!f`r?1ils{Xj;j$BSbJ!jQ5ISsDRxq>pkDf@g{zoV3vu!SuXi ze6UL-wJbwFdEXO^H1FDYu~+3e+~45&=>`AkE|2`BkW@$$xDTvH*4XLelHbX^BEOsr z`tbgH7~%8J^?r$T(h2rkMdEXyfZDfD5OwUVO(__& z_hxJLAs7X!LVCWnG=OSMa^cykx*nErO(5_`qK3LU=2U@*{+*1y4gxAIynnxsC=qBV z*rJbWyQine>tvoj;G!MsaJKS;*LoOzHTgbjV<(8Lv^LJQjqm_v-H2PIsq_>sI!!4*Yw;3S!*7g2lwP z$lx;^3Hi%W|G|j^tJa#|xdFqp;$tUl??unETflBKl$nP|1=P>i-^f_#7vXcZLOp{> zu}`u?dB-X~xI4HjdbeV|cSad>B?UzABg~J4-dtbfD3sVB_+UPx-rPFgovr?vTu5*j zUo@@&gC&Q-l~GXcMm9ZtJyU9HfcuK#nLCH_0viOFb9ooC?HnNQTEt)aXZ(C?OMLQy z%m*lv5Taj9NFQ4B}2Gx@dW@$3Di}D1oYpX0KcEG|9YkRgsl~qF` zx{lPz7^F(Q#Hw(l&FthV7aUv7Y{_pK8Gc{+tDmJ4W!wpWS1!0^yI5Zqnp#ESNO{{R z#47T9yvOx#yplYb+dFFeC?KGKj}T8PVToEG?wvNjpnYgS5ZpJa+ZYj9ZrbUS?c46L zD@rQK<4@gi&MwTHSRt?))%L8UjI zsc4tJyE5Xkoz_4d=u(3e^aA!P>3%oj-7#oM0JS z+_BwXn7zm4i;Y13V$ z2EiwDnT>8y5yj7}x45Q5H9=_0dg_*aVX)ldf=FGMSXkMUAC}1^LPQG$fzq{xrk?^g ziDRT`?WSLZM79x>g#*6Y@+aO10w@PwF4lcvg8v@cf1defR|8u7cW2h+6cor+iND1% z!I$7C{9>+Ya5N==Q(E0+9+AQYL?*}X+2yN19j21%qK~KP-tS2G)H9Iga{M%htKnB+ z63HdvqJL!AaYl0U%*^%bcWac6sDn}PAZyNad`(XY$;^KbfVqN>xd0qBp^1}(8SyMB zU1ovGg z4zq@^Jje#4G!emOCdJz?cZb(y^V#Uh+cW&2pO#+@3@*C@YXPcxQ+?{R1^)HgoO7!Z zo%&CQI5p8xQC#RL@dv01U26tpm&;F;EG~?z zBoz$RK8Y4v>qbwaBod5h>lbKv(WxJNkk#A&V^Lav`MhcM2ggD3?y7dnOuWIB{1$iE zY3s!t^`|9oo7O<;Yg2TonTh-8Y$^@UnVHZ6=iHNb)rsF{8nr@hnFFssUr`q8Ldk+3 z`ae%|2F{&{glHi#x~{%7+vK=aXQVUCq_B~#@*YS|pf{_wX%zJ&Uh2=$`{<>}0J4vd z9k|pVLbmh$;oQ$Q8x;zPI}jg-LIAh=X8v-Z)2;5##Lad0UNu?;P=+EA>_BO|G{^9VF{S+J1OMV=0ko`9sL-@h6&4M@QSUKSY{D^4u0X4ct7( zulHGV78Iw2V$WatLm%`t=JlN=G85sfZ{{SnGuvrDx#uW#=k$geUlWr4;Yc&3{d8|p zAT(*+m{fqjRq1=V!a6KD^+ld4&5KtH4sL~GR1^$(dwpKWbMIl^N3j3leX3{F;zAH~*YOh2iYXMc{w)p= zby0|l-RBgXKEc7~qZ7=)I7H9wb38p@Zk?~UEUnDSK}L#X!iiw4$J?$;j6Ut`7NIZL zYyz?ew;!4FTFhGDWPKDjms80W9mJ7ifDGO{P-M;|#Q#>deKeS&>ehds(;pL`@}Y^ zfb_${J%v(I$de&c+{TuPg=O|PEjkkQufmX17vQ(|hjnv@L!=3V_7caN#hd|^E1{I6 zx8E}oxN`AvCyeUV18;%MUz+CU)zgVYJQA_EOE_?F&cbL;*xY^1&R3jjsC_x~0%I}g zJat_oUn-hCvUc5z!?pNy40ervf^nk{-FP|IRKqWtZBQwG-tXjQmhYaLhj3JlB9TEW z7xAnVEy%Dl;W1a6LTV&|oNc@L;nI2%Rt|V`XB*FQvu8iD&!0Kw z_cdO!2t^+%t^!{>_;4|bl+}t8Z=uJyNMOpYd&~my0+uTRaP_@G&vwXuG8tEFquYl1 zc1ULd`GXe9w75yTNyL~%3;8(q+8O`Y!eD}>5v$0>iRd@^fWL=BdoK#y2kTY(i8fTz z)-Y)<8I5g>MNgCgG33zO1aoY&0ka+R&`|R+beMa!9MRMxSYfu0gbrHQhNe|*nvwZ$ z_p5SE9=p=NN!Jy)2L!hKOwomF1ze6TI-rGtC&jT8xW)t|B=glz^nZ3oi-Z3hu7#>t zB!L6Y9^zhJKkf3_fZG>oiuvn4rXgtZ@y0P`*_GyIJZ)q8Elwd zB%BL`p}EIKCliq6m>k&0&PDDh@53mJYqrU3lb^K(`Ay<- zC6}Z)bvUD9@j!D4AA1x7?=OlUZ;56n&d@cH-_IIn=tz(pKWnNK1>K+!J6b>l#Ge!g?*%k@8dGe$;lVfR~fF&XNf=I>}b{=q^eApm-i{%6#yMcp5S-? zebV+fUx69n(o7Y5Kt=L;f4xs&zxiuxoZ+cNcIzJcY&>>@h)Fhz*@|U4?C;zvXU;M457|@CPID7}asVIR_-SC)3x`VmM)<5!;zHZmnKMN0TuNYk|@DdT!Q5 z-^uav8i|kwSB+r3AVib)ZR)EU{3d|Oo^4b&W&tIo!ICuO`teGCt0a_4 zcjD>cF`D`Di;~F^OM}j>XQL+s5svyUPe*qm#k4$C7!k;|a`q(XE5J%LL44m_KUd3~ z8VIPkMve59Wi$}CSDwqCiugD>&pCFhug@~@QZLZ!b@xVhf8geT{DBqqCHsIe7#S?7IfM6L4E^Iz@tDB?=>#s)ql zwIDu+M{|@9j$I(IG-j0lwD~)p6qaUPRSn<7Lm!9B+C#ZyDy=-|(Eh7xWq#_#9+&iK z+btmMs;7c)12{sieL1H`U9W5zaC0yQr$B9_fC1OB$HL0FlBbF8L(ay+Vu|eZ#m38i zrm%c@+wapB9_+u`BSmj=Cjyzny0_U(6>}Mx9>wyKd~$c(DHHIF)6CL(< zc+SL$Q1npIZ--{sRm&Wv>~qYg$p!Sz9g&uaso&Ny-333gqE%|z{DeB|@zC%y$drw2 zF9Fx}tzv|YR@S76tQL{_Z*5VDj%RXRmvV1dx?523X`gYQmxwtKywSIPYu^ z%lBNYtdSJaxVQB4a;gab@QnJeC2B-oRW~SmauO5UADaC;jw$L53m(zFB&(divg|+E zy5Y9_`wRL=wjIUhCEctN)7b`S*4GUkW_)x(yKB~-_xEMtZ@eL*yAf*`-C25==8u+w zkZF`0*NUyKB&Tv@ka75wa3)FnAq@KLWKS{?y|pVLVWNiw1myM+Qr4PK$DmpxzKwXA zqUT)C6BT|djf-uMA*4erDr0~+p%J7>$sG0US72%qf|(E)hlZkQZs!jNJ&55-wt}tb zS@_nIgupCgZ)c|t#qdyp-baiYqh{p}2nV+gkHaiRkV!?K@HBa-x2c;=6Grt{07>WbiwNakLZLFf2m2$5Bpe`sb=(*Lov~D8Y$7~rWtkn2EsIdh99HR8l-yd>$At~orYtx8J#S_UO#v|ca7ehj~fY#PU)RW8DVp%3j)Bw!OI@w4E_6b zUsW7}AEJ=odW5h%mDe?lzTAHHJo$Mo%XQUfQ*aNv6wwDM5*s{ukp(GAKajWb&}B2q zf6rgoJ2w{a@Dj|X2suCjg1QZeB}^V`gK6}cZbgmPf{QhGD4mpT%WV@I2|W;eNw*o3 z(ZF$Ot{cIZaQ$zMfZ-dTX#dZ}fH%m_PYGs0J49l!pfMvf>pfxQK0S9|*5KfkycnOV zl4DH5j;IUfKxVWj0t$=bCNcq|BC_GDjOd0>;&knC#f~&__rf7BOy^t8(RQzA6u-x# zEnmE@uSiD}YaAwy>gW>SFOu$51r-GK@vR`p`D@|A_19rsf!Sz~Fp*uIVNjkVSMak^ zX+#`PFW3HF`7G#KYL`^qX`}@Mw_EILR+_|bP&0;okIqTdYbQ{6QgekfMwTMz0mu)6 zl+&-|QR5sVpik8iy1yRi)8Jf4ck(0LAR-bLpF1fBBLnZRm#zsCJFK9?q zo!V&2&41x2;0?;9wR<6*YcCDvh%-btfr57PGmYsw&L<7g69FAT7Rb7(gdRi(y!7<5l{Y>c( zD`9v%4*j^tW&Dxvd-0(B?7t#uRxUvYjR^PRMp3E#v4j&%viKdZtF@S5kb{Isgx{D6 z$Kk;&22kc?e#pf!qrdqw@0s)O84m3%!5{c33eJsx4`VYbN#sjxntnXcucODWbX9^u zbg2wKlOaPYS@Uhc{n6ycwC9WG5A4P*s#^q@t=}6=8d63G-;7WX>$>l97bRLQSENLI zFRVs^93qKy0Vm}|?Jd`cNbQsh^3$BVT@dvGngZSBbwcMXlgDzmHHFa0HMa@%L|E=96aee4p!g^N7+jbIV=JpG000DEP0)Ikq) zYm)LT4d#`7t*jh56b3zO&SvTvPMdhRZV-i*+G0myKr`Y7sBh`HJE{;cD^$w9(W$%( z7d}hNBbq~W3Xuk|HUQpKlAInTeTU5*DM`_e0PE3hrQo0I>)yqa#}_ID{z}>vm?vVX z<{JxUO+Mt!^@-YMIjJB!KW&?O!y{(|pxrLikv_(q`7wg%)mESGP&ZKXv3UEF)Oc?> zwmRm1DHWi3Y8^Jvhm5rkkC}Po{KKnar39IZx&@O>l8vQDRIa5k<1&H-&f!42tipZD z7w^ABRW{sIJM24`$7@pf9gzv^9#3Ii_2hgA;qs4M(?u%QkRrA##$haIo#|^_BKxpJ z!6Wfx1M(oeDsi~FnHi`r6`+vl3oB#j@x-*j$iOh)8h95U`6Vu{SS^o#A&p+8?3F+> z#)uNRC}M!dahr{U84vzzs=pKi*sywy3!+}M8{yM%#gvJ5z?3$~RDzwCnyN*F0r~cA z+?(Hhi>*VzWrdR~;A8inG{EOR{i!(|>;r-vJ~+A!JWO~~H{+ISykqQr?>KKf;y5fB z|ItYh14SBCDh5ux|Ab0Wc8-KR)gX7XXava`(}q^yK24A)QQ#=_;J^l@=}RAMza4`8 ztt3lJW9f+4TnIXN35fnPg*$m+`rCv-#eLL580Ps}vkLdfT&aSJz*?SrH~UDwM5vij z{>+VkWhDe)DKr&yMTk`YOp57nf1a&px0tL~^Cas;yAx_;L_*J4lv2w^h6eRKp0yM* zPip3I?1fl}rQ@3n`i449H z5_vdX{1zBU?FiR;;LYH7jL@MiKtX&akvslAVDU?Ul?48S15!@ZIATWe>|N$)Fb;Ex zW=s?12XRpiVl-kJ7>mU|dM+MvQx=Bjd_>t|Y^!ijgRdSKBd5y>BpW(3lvicq6ima? zf|mKshtbUea2W`9_~-q8ZnAzPM{a})P}|t>vU+{98Sx_&;ffu>=4~dAx&+>9Ob^uW z8szI#bSoSMdM02w#?**Q)w{#$)XhG{J>pnQQn`h1+tWh>q98v#)a1p3fhuGv7`rI_ zBW;IXe|&MBJRCPpM?T!V1m@i^vUow#m~_Be0h6%KdI(4MF?c`m%9?fTAd+~OU+4EI_0I7myA&iPp>EKuy;7BeR$4i4NQfAAOz@je=HO$)iPmivK;@MJ7Y}t>#-48~cSu!6q zh%qbKCm52$9BvXi;zVCMg^{7Zpia&j#1&2B6*Xc*m(UY8>g3!;or3Q4?<)0oI`(>G zzS^hye7_t8Z%ccaYI^p6iJ7{)Z}=@Tda99A9>O$_Wl6kY5m$mC#TNrd$BMzJXITDc ze}Bi(R{qe!W2?Itg>vc2cW{vxBmqg5Wz>Kq?0O3xAz@2pM}mozY3SZDlS^g6%Uws_ zHzb&FoG?^k$dN~!B!VdN1`NiP7@iIyiCh_k?2jc^&O$|?BsR)d8HDj*f1GS59M^kLwL1XXe zwsT`SYb~JTgIp5yF$^EJ7O@k)E#16R@X~}+sHlUQ_DPqKjduC;a%WeBI=jX{+=Tt} zQoHki7)K>uz)G{yCkf=-zv`%U9xcx9RFUv>=dnR zf7Fac(Zt{ghfd+#yXWWKRBAU^O0WA|gHRA+@;{XYtaAY)rwZE&%K}fiq-k1R5kQxg zaT0|NR4sU`MeGXPd}8#1Wcud6!!my|!uXG3$qxBKcoyR|oug{N{r!hpubQj9OG4Q# z`0q&ok=Me>2(_ug8pA44CL8w}`Y=LpnD_jDgi;6Ln9V1FJE8~Ng-}gPEh#BY&rlJ) z|Ij5RNQ(kf9};48S&?Yris{5{%`kn>UpCcm|ItrnpPm`2afZJv;=Y*1sto<bqTIk-hj8R{OeYEzvk8S(CPn+hOuA`E$)*fX>Fb{j-jiHdZFOc zk?ymp*8jk)q&*jY=l>s=#Ywn|p7B30tLpU~i*t{C$v Date: Fri, 11 Jul 2025 11:22:08 +0100 Subject: [PATCH 167/212] Updates the bulk action blank state images to the latest UI From ff56de17b31cce29b6a8553490bdefba7043276b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 11:22:08 +0100 Subject: [PATCH 168/212] Updates the bulk action blank state images to the latest UI From 86864dd07baee03c3fc57b091a04eb7f3af5d24f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:04:26 +0100 Subject: [PATCH 169/212] Added R and C shortcuts back in --- .../route.tsx | 241 +++++++++++------- ...ectParam.env.$envParam.runs.bulkaction.tsx | 10 +- 2 files changed, 156 insertions(+), 95 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 2b02b426e3..1df02f7db2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -2,7 +2,12 @@ import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid"; import { type MetaFunction, useNavigation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { Suspense } from "react"; -import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { + TypedAwait, + typeddefer, + type UseDataFunctionReturn, + useTypedLoaderData, +} from "remix-typedjson"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence"; @@ -20,10 +25,11 @@ import { ResizablePanelGroup, } from "~/components/primitives/Resizable"; import { SelectedItemsProvider } from "~/components/primitives/SelectedItemsProvider"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { Spinner } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; -import { RunsFilters } from "~/components/runs/v3/RunFilters"; +import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; import { $replica } from "~/db.server"; @@ -31,6 +37,7 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; @@ -106,13 +113,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export default function Page() { 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(); return ( <> @@ -152,94 +155,13 @@ export default function Page() { > {(list) => { - const isShowingBulkActionInspector = - searchParams.has("bulkInspector") && list.hasAnyRuns; return ( - - -
- <> - {list.runs.length === 0 && !list.hasAnyRuns ? ( - list.possibleTasks.length === 0 ? ( - - ) : ( - - ) - ) : ( -
-
- -
- {!isShowingBulkActionInspector && ( - 0 ? "selected" : undefined - )} - LeadingIcon={ListCheckedIcon} - className={selectedItems.size > 0 ? "pr-1" : undefined} - > - - Bulk action - {selectedItems.size > 0 && ( - {selectedItems.size} - )} - - - )} - -
-
- - -
- )} - -
-
- {isShowingBulkActionInspector && ( - <> - - - 0} - /> - - - )} -
+ ); }}
@@ -251,6 +173,139 @@ export default function Page() { ); } +function RunsList({ + list, + selectedItems, + rootOnlyDefault, + filters, +}: { + list: Awaited["data"]>; + selectedItems: Set; + rootOnlyDefault: boolean; + filters: TaskRunListSearchFilters; +}) { + const navigation = useNavigation(); + const isLoading = navigation.state !== "idle"; + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { has, replace } = useSearchParams(); + + // Shortcut keys for bulk actions + useShortcutKeys({ + shortcut: { key: "r" }, + action: (e) => { + replace({ + bulkInspector: "true", + action: "replay", + mode: selectedItems.size > 0 ? "selected" : undefined, + }); + }, + }); + useShortcutKeys({ + shortcut: { key: "c" }, + action: (e) => { + replace({ + bulkInspector: "true", + action: "cancel", + mode: selectedItems.size > 0 ? "selected" : undefined, + }); + }, + }); + + const isShowingBulkActionInspector = has("bulkInspector") && list.hasAnyRuns; + return ( + + +
+ <> + {list.runs.length === 0 && !list.hasAnyRuns ? ( + list.possibleTasks.length === 0 ? ( + + ) : ( + + ) + ) : ( +
+
+ +
+ {!isShowingBulkActionInspector && ( + 0 ? "selected" : undefined + )} + LeadingIcon={ListCheckedIcon} + className={selectedItems.size > 0 ? "pr-1" : undefined} + tooltip={ +
+
+ Replay + +
+
+ Cancel + +
+
+ } + > + + Bulk action + {selectedItems.size > 0 && ( + {selectedItems.size} + )} + +
+ )} + +
+
+ + +
+ )} + +
+
+ {isShowingBulkActionInspector && ( + <> + + + 0} + /> + + + )} +
+ ); +} + function CreateFirstTaskInstructions() { const organization = useOrganization(); const project = useProject(); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index d784c98f5c..1b085f3fb9 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -199,6 +199,9 @@ export function CreateBulkActionInspector({ const environment = useEnvironment(); const fetcher = useTypedFetcher(); const { value, replace } = useSearchParams(); + const [action, setAction] = useState( + (value("action") ?? "replay") as BulkActionAction + ); const location = useOptimisticLocation(); const user = useUser(); @@ -208,8 +211,11 @@ export function CreateBulkActionInspector({ ); }, [organization.id, project.id, environment.id, location.search]); + useEffect(() => { + setAction((value("action") ?? "replay") as BulkActionAction); + }, [value("action")]); + const mode = value("mode") ?? "filter"; - const action = value("action") ?? "replay"; const data = fetcher.data != null ? fetcher.data : undefined; @@ -323,7 +329,7 @@ export function CreateBulkActionInspector({ { replace({ action: value }); }} From aecceec2d5890ef1a20e5653569d494417584472 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:04:26 +0100 Subject: [PATCH 170/212] Added R and C shortcuts back in From e50a882cbd4493deb6ccd48a33a8f3b2b51816ed Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:04:26 +0100 Subject: [PATCH 171/212] Added R and C shortcuts back in From a5c2dd0fc3953190413330bbd0750bf4de4624da Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:21:23 +0100 Subject: [PATCH 172/212] Fix for selecting a single run --- ...projects.$projectParam.env.$envParam.runs.bulkaction.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 1b085f3fb9..12b70dc236 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -107,7 +107,11 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [ z.object({ mode: z.literal("selected"), action: BulkActionAction, - selectedRunIds: z.array(z.string()), + selectedRunIds: z.preprocess((value) => { + if (Array.isArray(value)) return value; + if (typeof value === "string") return [value]; + return []; + }, z.array(z.string())), title: z.string().optional(), failedRedirect: z.string(), emailNotification: z.preprocess((value) => value === "on", z.boolean()), From e808156f6929840b52fd8e20a293d71ac1d99ac0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:21:23 +0100 Subject: [PATCH 173/212] Fix for selecting a single run From 10377de19e8021482fe991f25596313817d1ef13 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:21:23 +0100 Subject: [PATCH 174/212] Fix for selecting a single run From 027b4f4d552adde1bdc1dcc6818a63ebbf024f78 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:38:17 +0100 Subject: [PATCH 175/212] Improved exit icon, added shortcut to modal --- ...jects.$projectParam.env.$envParam.runs.bulkaction.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 12b70dc236..1ddf11e831 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -246,7 +246,7 @@ export function CreateBulkActionInspector({ project, environment )}?${closedSearchParams.toString()}`} - variant="minimal/medium" + variant="minimal/small" TrailingIcon={ExitIcon} shortcut={{ key: "esc" }} shortcutPosition="before-trailing-icon" @@ -389,7 +389,7 @@ export function CreateBulkActionInspector({ key: "enter", enabledOnInputElements: true, }} - disabled={impactedCountElement === 0} + disabled={impactedCountElement === 0 || isDialogOpen} > {action === "replay" ? ( Replay {impactedCountElement} runs… @@ -436,6 +436,11 @@ export function CreateBulkActionInspector({ form="bulk-action-form" variant={action === "replay" ? "primary/medium" : "danger/medium"} disabled={impactedCountElement === 0} + shortcut={{ + modifiers: ["meta"], + key: "enter", + enabledOnInputElements: true, + }} > {action === "replay" ? ( Replay {impactedCountElement} runs From d92e80ee7ddf0bd318c4c3d43cbe2a72857dc581 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:38:17 +0100 Subject: [PATCH 176/212] Improved exit icon, added shortcut to modal From 632bbee0304ef6b9b73cb9014e0b2f21c1768854 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:38:17 +0100 Subject: [PATCH 177/212] Improved exit icon, added shortcut to modal From 2b8bae896ab4a79c8f48ee1fa1f0f72ea2a0acd8 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 178/212] Tidy imports --- .../route.tsx | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index 32fa281b89..872aec128a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -1,40 +1,34 @@ -import { ArrowPathIcon, BookOpenIcon } from "@heroicons/react/20/solid"; +import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { Form, useRevalidator } from "@remix-run/react"; import { ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; +import { BulkActionStatus, BulkActionType } from "@trigger.dev/database"; +import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; -import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; -import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { - Table, - TableBlankRow, - TableBody, - TableCell, - TableHeader, - TableHeaderCell, - TableRow, -} from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; -import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; -import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { useEventSource } from "~/hooks/useEventSource"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { BulkActionPresenter } from "~/presenters/v3/BulkActionPresenter.server"; +import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; +import { formatNumber } from "~/utils/numberFormatter"; import { EnvironmentParamSchema, v3BulkActionPath, @@ -43,14 +37,6 @@ import { v3RunsPath, } from "~/utils/pathBuilder"; import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server"; -import { logger } from "~/services/logger.server"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { Form, useRevalidator } from "@remix-run/react"; -import { BulkActionStatus, BulkActionType } from "@trigger.dev/database"; -import { formatNumber } from "~/utils/numberFormatter"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { useEventSource } from "~/hooks/useEventSource"; -import { useEffect } from "react"; const BulkActionParamSchema = EnvironmentParamSchema.extend({ bulkActionParam: z.string(), From 0dfab940f23ab59503187c9a0387320e49abbf00 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 179/212] Tidy imports From 6a4d71bce05a776b175d5cd7b6acb3885d319e05 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 180/212] Tidy imports From 6e025bd17125d075a07eb92a202c59113af1c8a2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 181/212] Tidy imports From 62bed7e629550fe7ecaf55f7ee7f623535de728c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 182/212] Tidy imports From 99d842f8031d2bb1d82024a2a5c89d5e7f65d4c7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 183/212] Tidy imports From edd4ecf5a8a5095b1462a1f2374190ee8077c892 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 184/212] Tidy imports From b968e14ea5d7b595960c4e5c2dbda8b6f5107f34 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 185/212] Tidy imports From f7fabfb20ddcd766af7139719aa2f1d75018b852 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 186/212] Tidy imports From b91f1eb96bf1239599f942106810b5ddae6bb1b3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:24:03 +0100 Subject: [PATCH 187/212] Tidy imports From 1ae7f0ca68ec7b21df29c441c253853f5a8a5c84 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:49:06 +0100 Subject: [PATCH 188/212] Fix for grid layout when 1 page of bulk actions visible --- .../route.tsx | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index 94db7362e5..f44ce5904d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -2,16 +2,12 @@ import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; import { Outlet, useParams, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; -import { schedules } from "@trigger.dev/sdk"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { BulkActionsNone } from "~/components/BlankStatePanels"; -import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { LinkButton } from "~/components/primitives/Buttons"; -import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue"; import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; @@ -30,13 +26,8 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue"; import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; -import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; -import { - ScheduleTypeIcon, - scheduleTypeName, - ScheduleTypeCombo, -} from "~/components/runs/v3/ScheduleType"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; @@ -44,7 +35,7 @@ import { useProject } from "~/hooks/useProject"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { - BulkActionListItem, + type BulkActionListItem, BulkActionListPresenter, } from "~/presenters/v3/BulkActionListPresenter.server"; import { requireUserId } from "~/services/session.server"; @@ -54,7 +45,6 @@ import { EnvironmentParamSchema, v3BulkActionPath, v3CreateBulkActionPath, - v3SchedulePath, } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { @@ -154,20 +144,20 @@ export default function Page() {
1 ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[auto_1fr]" + totalPages > 1 ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[1fr]" )} > -
-
+ {totalPages > 1 && ( +
-
+ )} - + {totalPages > 1 && (
+ ID From ed7547b7be903934a28f53669ae71120952f7747 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:49:06 +0100 Subject: [PATCH 189/212] Fix for grid layout when 1 page of bulk actions visible From e81b05e70fb95e1aeed36e161f9e5564dbe96b91 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 12:49:06 +0100 Subject: [PATCH 190/212] Fix for grid layout when 1 page of bulk actions visible From 474b46ffbb84f5ec76be1e394b7d2b0c53894dff Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:50:07 +0100 Subject: [PATCH 191/212] Removed the ... on the abort button --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index 872aec128a..ee1e4c3a3c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -174,7 +174,7 @@ export default function Page() { {bulkAction.status === "PENDING" ? (
) : null} From 9f5bb446bb415d8b3c3ae92d35c2091dacc911e6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:50:07 +0100 Subject: [PATCH 192/212] Removed the ... on the abort button From 4637b43741720f3314528337ad4098a25cf13c1b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:50:07 +0100 Subject: [PATCH 193/212] Removed the ... on the abort button From b24a0c5bf88d33eed7495284cd3e30b49b54fa11 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:50:07 +0100 Subject: [PATCH 194/212] Removed the ... on the abort button From ec14e03b968fdfe2af371abea1b978070dfcaad5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 12:59:28 +0100 Subject: [PATCH 195/212] Animate the progress bar --- .../route.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index ee1e4c3a3c..14296d48d4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -3,6 +3,7 @@ import { Form, useRevalidator } from "@remix-run/react"; import { ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { BulkActionStatus, BulkActionType } from "@trigger.dev/database"; +import { motion } from "framer-motion"; import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -290,17 +291,17 @@ function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
- } + - } +
From 400b0b2abf04fad07d7da23e89e26cb17b36a46f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 13:27:15 +0100 Subject: [PATCH 196/212] Set TZ="UTC" in the env example --- .env.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index cf1245b434..34bbb272e7 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ APP_ORIGIN=http://localhost:3030 ELECTRIC_ORIGIN=http://localhost:3060 NODE_ENV=development +# Set this to UTC because Node.js uses the system timezone +TZ="UTC" + # Redis is used for the v3 queuing and v2 concurrency control REDIS_HOST="localhost" REDIS_PORT="6379" @@ -77,4 +80,4 @@ POSTHOG_PROJECT_KEY= # These control the server-side internal telemetry # INTERNAL_OTEL_TRACE_EXPORTER_URL= # INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1 -# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0, +# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0, \ No newline at end of file From 2fa4bd818c53978911654eabeda0119d0b4bba80 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 11 Jul 2025 14:54:44 +0100 Subject: [PATCH 197/212] Filter summary in the bulk inspector --- .../components/BulkActionFilterSummary.tsx | 241 ++++++++++++++++++ .../v3/BulkActionPresenter.server.ts | 22 ++ .../route.tsx | 16 +- ...ectParam.env.$envParam.runs.bulkaction.tsx | 232 +---------------- 4 files changed, 285 insertions(+), 226 deletions(-) create mode 100644 apps/webapp/app/components/BulkActionFilterSummary.tsx diff --git a/apps/webapp/app/components/BulkActionFilterSummary.tsx b/apps/webapp/app/components/BulkActionFilterSummary.tsx new file mode 100644 index 0000000000..9a815c08a4 --- /dev/null +++ b/apps/webapp/app/components/BulkActionFilterSummary.tsx @@ -0,0 +1,241 @@ +import { z } from "zod"; +import { + filterIcon, + filterTitle, + type TaskRunListSearchFilterKey, + type TaskRunListSearchFilters, +} from "./runs/v3/RunFilters"; +import { Paragraph } from "./primitives/Paragraph"; +import simplur from "simplur"; +import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v3/SharedFilters"; +import { formatNumber } from "~/utils/numberFormatter"; +import { SpinnerWhite } from "./primitives/Spinner"; +import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid"; +import assertNever from "assert-never"; +import { AppliedFilter } from "./primitives/AppliedFilter"; +import { runStatusTitle } from "./runs/v3/TaskRunStatus"; +import { type TaskRunStatus } from "@trigger.dev/database"; + +export const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]); +export type BulkActionMode = z.infer; +export const BulkActionAction = z.union([z.literal("cancel"), z.literal("replay")]); +export type BulkActionAction = z.infer; + +export function BulkActionFilterSummary({ + selected, + final = false, + mode, + action, + filters, +}: { + selected?: number; + final?: boolean; + mode: BulkActionMode; + action: BulkActionAction; + filters: TaskRunListSearchFilters; +}) { + switch (mode) { + case "selected": + return ( + + You {!final ? "have " : " "}individually selected {simplur`${selected} run[|s]`} to be{" "} + . + + ); + case "filter": { + const { label, valueLabel, rangeType } = timeFilterRenderValues({ + from: filters.from ? dateFromString(`${filters.from}`) : undefined, + to: filters.to ? dateFromString(`${filters.to}`) : undefined, + period: filters.period, + }); + + return ( +
+ + You {!final ? "have " : " "}selected{" "} + + {final ? selected : } + {" "} + runs to be using these filters: + +
+ + {Object.entries(filters).map(([key, value]) => { + if (!value && key !== "period") { + return null; + } + + const typedKey = key as TaskRunListSearchFilterKey; + + switch (typedKey) { + case "cursor": + case "direction": + case "environments": + //We need to handle time differently because we have a default + case "period": + case "from": + case "to": { + return null; + } + case "tasks": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "versions": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "statuses": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + runStatusTitle(v as TaskRunStatus)))} + removable={false} + /> + ); + } + case "tags": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "bulkId": { + return ( + + ); + } + case "rootOnly": { + return ( + + ) : ( + + ) + } + removable={false} + /> + ); + } + case "runId": { + return ( + + ); + } + case "batchId": { + return ( + + ); + } + case "scheduleId": { + return ( + + ); + } + default: { + assertNever(typedKey); + } + } + })} +
+
+ ); + } + } +} + +function Action({ action }: { action: BulkActionAction }) { + switch (action) { + case "cancel": + return ( + + + Canceled + + ); + case "replay": + return ( + + + Replayed + + ); + } +} + +export function EstimatedCount({ count }: { count?: number }) { + if (typeof count === "number") { + return <>~{formatNumber(count)}; + } + + return ; +} diff --git a/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts b/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts index 40a3c648d9..cf62cd2653 100644 --- a/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts @@ -1,5 +1,8 @@ import { getUsername } from "~/utils/username"; import { BasePresenter } from "./basePresenter.server"; +import { type BulkActionMode } from "~/components/BulkActionFilterSummary"; +import { parseRunListInputOptions } from "~/services/runsRepository.server"; +import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; type BulkActionOptions = { environmentId: string; @@ -26,6 +29,13 @@ export class BulkActionPresenter extends BasePresenter { avatarUrl: true, }, }, + params: true, + project: { + select: { + id: true, + organizationId: true, + }, + }, }, where: { environmentId, @@ -37,11 +47,23 @@ export class BulkActionPresenter extends BasePresenter { throw new Error("Bulk action not found"); } + //parse filters + const filtersParsed = TaskRunListSearchFilters.safeParse( + bulkAction.params && typeof bulkAction.params === "object" ? bulkAction.params : {} + ); + + let mode: BulkActionMode = "filter"; + if (filtersParsed.success && Object.keys(filtersParsed.data).length === 0) { + mode = "selected"; + } + return { ...bulkAction, user: bulkAction.user ? { name: getUsername(bulkAction.user), avatarUrl: bulkAction.user.avatarUrl } : undefined, + filters: filtersParsed.data ?? {}, + mode, }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx index 14296d48d4..a81f39dbfc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -1,6 +1,6 @@ import { ArrowPathIcon } from "@heroicons/react/20/solid"; import { Form, useRevalidator } from "@remix-run/react"; -import { ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { BulkActionStatus, BulkActionType } from "@trigger.dev/database"; import { motion } from "framer-motion"; @@ -9,13 +9,13 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { BulkActionFilterSummary } from "~/components/BulkActionFilterSummary"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; import { Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { useEnvironment } from "~/hooks/useEnvironment"; @@ -233,6 +233,18 @@ export default function Page() { {bulkAction.completedAt ? : "–"} + + Summary + + + +
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx index 1ddf11e831..80ee5b2f03 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -13,6 +13,12 @@ import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import selectRunsIndividually from "~/assets/images/select-runs-individually.png"; import selectRunsUsingFilters from "~/assets/images/select-runs-using-filters.png"; +import { + BulkActionAction, + BulkActionFilterSummary, + BulkActionMode, + EstimatedCount, +} from "~/components/BulkActionFilterSummary"; import { Accordion, AccordionContent, @@ -93,11 +99,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return typedjson(data); } -const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]); -type BulkActionMode = z.infer; -const BulkActionAction = z.union([z.literal("cancel"), z.literal("replay")]); -type BulkActionAction = z.infer; - export const CreateBulkActionSearchParams = z.object({ mode: BulkActionMode.default("filter"), action: BulkActionAction.default("cancel"), @@ -364,7 +365,7 @@ export function CreateBulkActionInspector({ - {action === "replay" ? "Replay runs" : "Cancel runs"}
- ); } - -function BulkActionPreview({ - selected, - mode, - action, - filters, -}: { - selected?: number; - mode: BulkActionMode; - action: BulkActionAction; - filters: TaskRunListSearchFilters; -}) { - switch (mode) { - case "selected": - return ( - - You have individually selected {simplur`${selected} run[|s]`} to be{" "} - . - - ); - case "filter": { - const { label, valueLabel, rangeType } = timeFilterRenderValues({ - from: filters.from ? dateFromString(`${filters.from}`) : undefined, - to: filters.to ? dateFromString(`${filters.to}`) : undefined, - period: filters.period, - }); - - return ( -
- - You have selected{" "} - - - {" "} - runs to be using these filters: - -
- - {Object.entries(filters).map(([key, value]) => { - if (!value && key !== "period") { - return null; - } - - const typedKey = key as TaskRunListSearchFilterKey; - - switch (typedKey) { - case "cursor": - case "direction": - case "environments": - //We need to handle time differently because we have a default - case "period": - case "from": - case "to": { - return null; - } - case "tasks": { - const values = Array.isArray(value) ? value : [`${value}`]; - return ( - - ); - } - case "versions": { - const values = Array.isArray(value) ? value : [`${value}`]; - return ( - - ); - } - case "statuses": { - const values = Array.isArray(value) ? value : [`${value}`]; - return ( - runStatusTitle(v as TaskRunStatus)))} - removable={false} - /> - ); - } - case "tags": { - const values = Array.isArray(value) ? value : [`${value}`]; - return ( - - ); - } - case "bulkId": { - return ( - - ); - } - case "rootOnly": { - return ( - - ) : ( - - ) - } - removable={false} - /> - ); - } - case "runId": { - return ( - - ); - } - case "batchId": { - return ( - - ); - } - case "scheduleId": { - return ( - - ); - } - default: { - assertNever(typedKey); - } - } - })} -
-
- ); - } - } -} - -function Action({ action }: { action: BulkActionAction }) { - switch (action) { - case "cancel": - return ( - - - Canceled - - ); - case "replay": - return ( - - - Replayed - - ); - } -} - -function EstimatedCount({ count }: { count?: number }) { - if (typeof count === "number") { - return <>~{formatNumber(count)}; - } - - return ; -} From 26d5d5c4e17fedc02e74b5b71d7dd063e3703e83 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 11 Jul 2025 13:54:32 +0100 Subject: [PATCH 198/212] Improves the pagination styling --- .../app/components/primitives/Pagination.tsx | 93 ++++++++++++++----- 1 file changed, 68 insertions(+), 25 deletions(-) diff --git a/apps/webapp/app/components/primitives/Pagination.tsx b/apps/webapp/app/components/primitives/Pagination.tsx index 186878c6d7..f465083710 100644 --- a/apps/webapp/app/components/primitives/Pagination.tsx +++ b/apps/webapp/app/components/primitives/Pagination.tsx @@ -19,32 +19,75 @@ export function PaginationControls({ } return ( -