Initial work on the new bulk actions

This commit is contained in:
Matt Aitken
2025-06-26 12:44:31 +01:00
parent 425edeea04
commit b4d852d9d7
7 changed files with 396 additions and 207 deletions
@@ -91,6 +91,24 @@ export const TaskRunListSearchFilters = z.object({
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
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: {
@@ -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,
@@ -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<typeof loader>();
const { data, rootOnlyDefault, filters } = useTypedLoaderData<typeof loader>();
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 }) => (
<div
className={cn(
"grid h-full max-h-full overflow-hidden",
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_auto]"
)}
>
<Suspense
fallback={
<div className="flex items-center justify-center py-2">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading runs</Paragraph>
</div>
</div>
}
>
<TypedAwait resolve={data}>
{(list) => (
<>
{list.runs.length === 0 && !list.hasAnyRuns ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
) : (
<div
className={cn(
"grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden"
)}
>
<div className="flex items-start justify-between gap-x-2 p-2">
<RunsFilters
possibleTasks={list.possibleTasks}
bulkActions={list.bulkActions}
hasFilters={list.hasFilters}
rootOnlyDefault={rootOnlyDefault}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
allowSelection
/>
</div>
)}
</>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="runs-main" min={"100px"}>
<div
className={cn(
"grid h-full max-h-full overflow-hidden",
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_auto]"
)}
</TypedAwait>
</Suspense>
<BulkActionBar />
</div>
>
<Suspense
fallback={
<div className="flex items-center justify-center py-2">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading runs</Paragraph>
</div>
</div>
}
>
<TypedAwait resolve={data}>
{(list) => (
<>
{list.runs.length === 0 && !list.hasAnyRuns ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
) : (
<div
className={cn(
"grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden"
)}
>
<div className="flex items-start justify-between gap-x-2 p-2">
<RunsFilters
possibleTasks={list.possibleTasks}
bulkActions={list.bulkActions}
hasFilters={list.hasFilters}
rootOnlyDefault={rootOnlyDefault}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
allowSelection
/>
</div>
)}
</>
)}
</TypedAwait>
</Suspense>
<BulkActionBar />
</div>
</ResizablePanel>
{isShowingBulkActionInspector && (
<>
<ResizableHandle id="runs-handle" />
<ResizablePanel id="bulk-action-inspector" min="100px" default="450px">
<CreateBulkActionInspector filters={filters} />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
)}
</SelectedItemsProvider>
</PageBody>
@@ -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<typeof loader>();
const lastSubmission = useActionData<typeof action>();
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 (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className="whitespace-nowrap">Create a bulk action</Header2>
<LinkButton
to={`${v3RunsPath(organization, project, environment)}${location.search}`}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Form
method="post"
action={`/resources/orgs/${organization.id}/projects/${project.id}/environments/${environment.id}/runs/bulkaction${location.search}`}
className="w-full"
>
{data?.count}
<Button LeadingIcon={XCircleIcon} type="submit" variant="danger/medium">
Cancel X runs
</Button>
</Form>
</div>
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
<Button
type="submit"
variant="tertiary/medium"
LeadingIcon={action === "replay" ? ArrowPathIcon : XCircleIcon}
leadingIconClassName={cn(
"w-[1.3rem] h-[1.3rem]",
action === "replay" ? "text-blue-400" : "text-error"
)}
>
{action === "replay" ? "Replay" : "Cancel"}
</Button>
</div>
</div>
);
// return (
// <Form method="post" action={`/resources/branches/archive${location.search}`} {...form.props} className="w-full">
// <input value={environment.id} {...conform.input(environmentId, { type: "hidden" })} />
// <input
// value={`${location.pathname}${location.search}`}
// {...conform.input(redirectPath, { type: "hidden" })}
// />
// <Paragraph spacing>
// This will <span className="text-text-bright">permanently</span> make this branch{" "}
// <span className="text-text-bright">read-only</span>. You won't be able to trigger runs,
// execute runs, or use the API for this branch.
// </Paragraph>
// <Paragraph spacing>
// You will still be able to view the branch and its associated runs.
// </Paragraph>
// <Paragraph spacing>Once archived you can create a new branch with the same name.</Paragraph>
// <FormError>{form.error}</FormError>
// <FormButtons
// confirmButton={
// <Button LeadingIcon={ArchiveIcon} type="submit" variant="danger/medium">
// Archive branch
// </Button>
// }
// cancelButton={
// <DialogClose asChild>
// <Button variant="tertiary/medium">Cancel</Button>
// </DialogClose>
// }
// />
// </Form>
// );
}
+100 -73
View File
@@ -1,4 +1,5 @@
import { type ClickHouse } from "@internal/clickhouse";
import { ClickhouseQueryBuilder } from "@internal/clickhouse/dist/src/client/queryBuilder";
import { type Tracer } from "@internal/tracing";
import { type Logger, type LogLevel } from "@trigger.dev/core/logger";
import { type TaskRunStatus } from "@trigger.dev/database";
@@ -12,7 +13,7 @@ export type RunsRepositoryOptions = {
tracer?: Tracer;
};
export type ListRunsOptions = {
export type FilterRunsOptions = {
organizationId: string;
projectId: string;
environmentId: string;
@@ -30,7 +31,9 @@ export type ListRunsOptions = {
batchId?: string;
runFriendlyIds?: string[];
runIds?: string[];
//pagination
};
export type ListRunsOptions = FilterRunsOptions & {
page: {
size: number;
cursor?: string;
@@ -43,77 +46,7 @@ export class RunsRepository {
async listRuns(options: ListRunsOptions) {
const queryBuilder = this.options.clickhouse.taskRuns.queryBuilder();
queryBuilder
.where("organization_id = {organizationId: String}", {
organizationId: options.organizationId,
})
.where("project_id = {projectId: String}", {
projectId: options.projectId,
})
.where("environment_id = {environmentId: String}", {
environmentId: options.environmentId,
});
if (options.tasks && options.tasks.length > 0) {
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks });
}
if (options.versions && options.versions.length > 0) {
queryBuilder.where("task_version IN {versions: Array(String)}", {
versions: options.versions,
});
}
if (options.statuses && options.statuses.length > 0) {
queryBuilder.where("status IN {statuses: Array(String)}", { statuses: options.statuses });
}
if (options.tags && options.tags.length > 0) {
queryBuilder.where("hasAny(tags, {tags: Array(String)})", { tags: options.tags });
}
if (options.scheduleId) {
queryBuilder.where("schedule_id = {scheduleId: String}", { scheduleId: options.scheduleId });
}
// Period is a number of milliseconds duration
if (options.period) {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", {
period: new Date(Date.now() - options.period).getTime(),
});
}
if (options.from) {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({from: Int64})", {
from: options.from,
});
}
if (options.to) {
queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to });
}
if (typeof options.isTest === "boolean") {
queryBuilder.where("is_test = {isTest: Boolean}", { isTest: options.isTest });
}
if (options.rootOnly) {
queryBuilder.where("root_run_id = ''");
}
if (options.batchId) {
queryBuilder.where("batch_id = {batchId: String}", { batchId: options.batchId });
}
if (options.runFriendlyIds && options.runFriendlyIds.length > 0) {
queryBuilder.where("friendly_id IN {runFriendlyIds: Array(String)}", {
runFriendlyIds: options.runFriendlyIds,
});
}
if (options.runIds && options.runIds.length > 0) {
queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds: options.runIds });
}
applyRunFiltersToQueryBuilder(queryBuilder, options);
if (options.page.cursor) {
if (options.page.direction === "forward") {
@@ -222,4 +155,98 @@ export class RunsRepository {
},
};
}
async countRuns(options: FilterRunsOptions) {
const queryBuilder = this.options.clickhouse.taskRuns.countQueryBuilder();
applyRunFiltersToQueryBuilder(queryBuilder, options);
const [queryError, result] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
if (result.length === 0) {
throw new Error("No count rows returned");
}
return result[0].count;
}
}
function applyRunFiltersToQueryBuilder<T>(
queryBuilder: ClickhouseQueryBuilder<T>,
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 });
}
}
@@ -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),
@@ -103,6 +103,17 @@ export function getTaskRunsQueryBuilder(ch: ClickhouseReader, settings?: ClickHo
});
}
export function getTaskRunsCountQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
return ch.queryBuilder({
name: "getTaskRunsCount",
baseQuery: "SELECT count() as count FROM trigger_dev.task_runs_v2 FINAL",
schema: z.object({
count: z.number().int(),
}),
settings,
});
}
export const TaskActivityQueryResult = z.object({
task_identifier: z.string(),
status: z.string(),