Bulk actions 2.0 (and switch all run listing to ClickHouse) (#2264)

* useSearchParams has

* useSearchParams has

* useSearchParams has

* Consistent way to get the run filters

* Consistent way to get the run filters

* Consistent way to get the run filters

* Initial work on the new bulk actions

* Initial work on the new bulk actions

* Initial work on the new bulk actions

* WIP actions and filtering

* WIP actions and filtering

* WIP actions and filtering

* Empty filter arrays are set to undefined

* Empty filter arrays are set to undefined

* Empty filter arrays are set to undefined

* WIP prisma schema

Removed extra runtimeEnvironmentId

* WIP prisma schema

Removed extra runtimeEnvironmentId

* WIP prisma schema

Removed extra runtimeEnvironmentId

* Migrations

* Migrations

* Migrations

* BulkActionGroup changed some columns around

* BulkActionGroup changed some columns around

* BulkActionGroup changed some columns around

* New badge variant, removed unused ones

* New badge variant, removed unused ones

* New badge variant, removed unused ones

* Bulk action button

* Bulk action button

* Bulk action button

* Make the next runs page the default now

* Make the next runs page the default now

* Make the next runs page the default now

* Improved the RadioButton style

* Improved the RadioButton style

* Improved the RadioButton style

* Remove the old bulk action bar

* Remove the old bulk action bar

* Remove the old bulk action bar

* More UI progress

* More UI progress

* More UI progress

* Lots of UI changes to the Runs page

* Lots of UI changes to the Runs page

* Lots of UI changes to the Runs page

* Fixed period filter resetting everything

* Fixed period filter resetting everything

* Fixed period filter resetting everything

* Improved the Switch secondary style

* Improved the Switch secondary style

* Improved the Switch secondary style

* Buggy filter fixes

* Buggy filter fixes

* Buggy filter fixes

* Improved the filter display and fixed a bug with search param from object

* Improved the filter display and fixed a bug with search param from object

* Improved the filter display and fixed a bug with search param from object

* Clear button is minimal

* Clear button is minimal

* Clear button is minimal

* Using a presenter now

* Using a presenter now

* Using a presenter now

* Bulk actions are created, but not actually processed (yet)

* Bulk actions are created, but not actually processed (yet)

* Bulk actions are created, but not actually processed (yet)

* Bulk replay/cancel is working

* Bulk replay/cancel is working

* Bulk replay/cancel is working

* Multiple fixes, added bulk column to PG

* Multiple fixes, added bulk column to PG

* Multiple fixes, added bulk column to PG

* Bulk action run filtering working using CH

* Bulk action run filtering working using CH

* Bulk action run filtering working using CH

* Replay setting the bulk id on the runs

* Replay setting the bulk id on the runs

* Replay setting the bulk id on the runs

* Properly cap the time when doing a bulk action

* Properly cap the time when doing a bulk action

* Properly cap the time when doing a bulk action

* If the bulk action isn't recent, add it to the dropdown anyway

* If the bulk action isn't recent, add it to the dropdown anyway

* If the bulk action isn't recent, add it to the dropdown anyway

* Blank version of the bulk actions page

* Blank version of the bulk actions page

* Blank version of the bulk actions page

* Individually selected runs working

* Individually selected runs working

* Individually selected runs working

* Use selected mode if runs are checked

* Use selected mode if runs are checked

* Use selected mode if runs are checked

* Added the modal

* Added the modal

* Added the modal

* Marked the old bulk actions stuff as deprecated

* Marked the old bulk actions stuff as deprecated

* Marked the old bulk actions stuff as deprecated

* Renamed bulk action file

* Renamed bulk action file

* Renamed bulk action file

* Bulk run filter with the name and a default

* Bulk run filter with the name and a default

* Bulk run filter with the name and a default

* WIP on bulk actions page

* WIP on bulk actions page

* WIP on bulk actions page

* Updated panel, added new truncated id component

* Updated panel, added new truncated id component

* Updated panel, added new truncated id component

* Style improvements to the radio buttons

* Style improvements to the radio buttons

* Style improvements to the radio buttons

* Added an option action completion email

* Added an option action completion email

* Added an option action completion email

* Adds a blank state for the bulk actions page

* Adds a blank state for the bulk actions page

* Adds a blank state for the bulk actions page

* Nicer completed email

* Nicer completed email

* Nicer completed email

* Don't open the bulk action panel if there are no runs

* Don't open the bulk action panel if there are no runs

* Don't open the bulk action panel if there are no runs

* Runs blank state and bulk action accordion

* Runs blank state and bulk action accordion

* Runs blank state and bulk action accordion

* Updates secondary/small switch style

* Updates secondary/small switch style

* Updates secondary/small switch style

* Pagination buttons no longer split in twain (WIP)

* Pagination buttons no longer split in twain (WIP)

* Pagination buttons no longer split in twain (WIP)

* Aborting working

* Aborting working

* Aborting working

* Bulk action live reloading

* Bulk action live reloading

* Bulk action live reloading

* ListPagination works correctly in all states

* ListPagination works correctly in all states

* ListPagination works correctly in all states

* Run page, show friendlyId instead of number

* Run page, show friendlyId instead of number

* Run page, show friendlyId instead of number

* Bulk action help open by default if you have none

* Bulk action help open by default if you have none

* Bulk action help open by default if you have none

* Extra status filtering step because of replication delay

* Extra status filtering step because of replication delay

* Extra status filtering step because of replication delay

* Wider bulk action onboarding

* Wider bulk action onboarding

* Wider bulk action onboarding

* More sensible widths on the bulk action side panel

* More sensible widths on the bulk action side panel

* More sensible widths on the bulk action side panel

* Border color tweak to the RadioButton

* Border color tweak to the RadioButton

* Border color tweak to the RadioButton

* Improved the accordion component hover states

* Improved the accordion component hover states

* Improved the accordion component hover states

* Updates the bulk action blank state images to the latest UI

* Updates the bulk action blank state images to the latest UI

* Updates the bulk action blank state images to the latest UI

* Added R and C shortcuts back in

* Added R and C shortcuts back in

* Added R and C shortcuts back in

* Fix for selecting a single run

* Fix for selecting a single run

* Fix for selecting a single run

* Improved exit icon, added shortcut to modal

* Improved exit icon, added shortcut to modal

* Improved exit icon, added shortcut to modal

* Tidy imports

* Tidy imports

* Tidy imports

* Tidy imports

* Tidy imports

* Tidy imports

* Tidy imports

* Tidy imports

* Tidy imports

* Tidy imports

* Fix for grid layout when 1 page of bulk actions visible

* Fix for grid layout when 1 page of bulk actions visible

* Fix for grid layout when 1 page of bulk actions visible

* Removed the ... on the abort button

* Removed the ... on the abort button

* Removed the ... on the abort button

* Removed the ... on the abort button

* Animate the progress bar

* Set TZ="UTC" in the env example

* Filter summary in the bulk inspector

* Improves the pagination styling

* Improves the pagination styling

* Delete old bulk action routes

* Removed old Postgres RunListPresenter

* Retry any replication error where the message contains "timeout"

* Increase wait to make test less flaky

* The test was using run id instead of friendly id

* Safer array access

* Remove error log if there's a bad status

* Nicer frontend type safety with the bulk action and mode

* Switched a log to a debug log

* Retry replication unless the error is a known non-retry error

Flip the strategy to retry by default

* Make ClickHouse required

* Backfill run replication admin API endpoint

* Set a CLICKHOUSE_URL for unit tests

---------

Co-authored-by: James Ritchie <james@trigger.dev>
This commit is contained in:
Matt Aitken
2025-07-14 15:50:01 +01:00
committed by GitHub
parent 6db1b71202
commit 630e9556b0
88 changed files with 3915 additions and 2121 deletions
+4 -1
View File
@@ -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=<URL to send traces to>
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
+1
View File
@@ -87,6 +87,7 @@ jobs:
MAGIC_LINK_SECRET: "secret"
ENCRYPTION_KEY: "secret"
DEPLOY_REGISTRY_HOST: "docker.io"
CLICKHOUSE_URL: "http://default:password@localhost:8123"
- name: Gather all reports
if: ${{ !cancelled() }}
@@ -0,0 +1,48 @@
export function ListCheckedIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.85001 3.82759L4.09139 5.06897L6.16035 3"
stroke="currentColor"
strokeWidth="1.65517"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.85001 8.82759L4.09139 10.069L6.16035 8"
stroke="currentColor"
strokeWidth="1.65517"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.85001 13.8276L4.09139 15.069L6.16035 13"
stroke="currentColor"
strokeWidth="1.65517"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 4H15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 9H15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 14H15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

@@ -11,14 +11,22 @@ import {
Squares2X2Icon,
} from "@heroicons/react/20/solid";
import { useLocation } from "react-use";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import openBulkActionsPanel from "~/assets/images/open-bulk-actions-panel.png";
import selectRunsIndividually from "~/assets/images/select-runs-individually.png";
import selectRunsUsingFilters from "~/assets/images/select-runs-using-filters.png";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { NewBranchPanel } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
import {
docsPath,
v3BillingPath,
v3CreateBulkActionPath,
v3EnvironmentPath,
v3EnvironmentVariablesPath,
v3NewProjectAlertPath,
@@ -36,12 +44,7 @@ import { StepNumber } from "./primitives/StepNumber";
import { TextLink } from "./primitives/TextLink";
import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { useFeatures } from "~/hooks/useFeatures";
import { DialogContent, DialogTrigger, Dialog } from "./primitives/Dialog";
import { V4Badge } from "./V4Badge";
import { NewBranchPanel } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
export function HasNoTasksDev() {
return (
@@ -569,3 +572,56 @@ export function SwitcherPanel({ title = "Switch to a deployed environment" }: {
</div>
);
}
export function BulkActionsNone() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<div>
<div className="mb-6 flex items-center justify-between border-b pb-0.5">
<Header1 spacing>Create a bulk action</Header1>
<div className="flex items-center gap-2">
<LinkButton
variant="primary/small"
LeadingIcon={PlusIcon}
to={v3CreateBulkActionPath(organization, project, environment)}
>
New bulk action
</LinkButton>
</div>
</div>
<StepNumber stepNumber="1" title="Select runs individually" />
<StepContentContainer className="mb-4 flex flex-col gap-4">
<Paragraph>Select runs from the runs page individually.</Paragraph>
<div>
<img src={selectRunsIndividually} alt="Select runs individually" />
</div>
</StepContentContainer>
<div className="mb-5 ml-9 flex items-center gap-2">
<div className="h-px w-full bg-grid-bright" />
<Paragraph variant="extra-small" className="text-text-dimmed">
OR
</Paragraph>
<div className="h-px w-full bg-grid-bright" />
</div>
<StepNumber stepNumber="2" title="Select runs using filters" />
<StepContentContainer className="flex flex-col gap-4">
<Paragraph>
Use the filter menu on the runs page to select just the runs you want to bulk action.
</Paragraph>
<div>
<img src={selectRunsUsingFilters} alt="Select runs using filters" />
</div>
</StepContentContainer>
<StepNumber stepNumber="3" title="Open the bulk action panel" />
<StepContentContainer className="flex flex-col gap-4">
<Paragraph>Click the “Bulk actions” button in the top right of the runs page.</Paragraph>
<div>
<img src={openBulkActionsPanel} alt="Open the bulk action panel" />
</div>
</StepContentContainer>
</div>
);
}
@@ -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<typeof BulkActionMode>;
export const BulkActionAction = z.union([z.literal("cancel"), z.literal("replay")]);
export type BulkActionAction = z.infer<typeof BulkActionAction>;
export function BulkActionFilterSummary({
selected,
final = false,
mode,
action,
filters,
}: {
selected?: number;
final?: boolean;
mode: BulkActionMode;
action: BulkActionAction;
filters: TaskRunListSearchFilters;
}) {
switch (mode) {
case "selected":
return (
<Paragraph variant="small">
You {!final ? "have " : " "}individually selected {simplur`${selected} run[|s]`} to be{" "}
<Action action={action} />.
</Paragraph>
);
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 (
<div className="flex flex-col gap-2">
<Paragraph variant="small">
You {!final ? "have " : " "}selected{" "}
<span className="text-text-bright">
{final ? selected : <EstimatedCount count={selected} />}
</span>{" "}
runs to be <Action action={action} /> using these filters:
</Paragraph>
<div className="flex flex-col gap-2">
<AppliedFilter
variant="minimal/medium"
label={label}
icon={filterIcon("period")}
value={valueLabel}
removable={false}
/>
{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 (
<AppliedFilter
variant="minimal/medium"
key={key}
label={filterTitle(key)}
icon={filterIcon(key)}
value={appliedSummary(values)}
removable={false}
/>
);
}
case "versions": {
const values = Array.isArray(value) ? value : [`${value}`];
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={filterTitle(key)}
icon={filterIcon(key)}
value={appliedSummary(values)}
removable={false}
/>
);
}
case "statuses": {
const values = Array.isArray(value) ? value : [`${value}`];
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={filterTitle(key)}
icon={filterIcon(key)}
value={appliedSummary(values.map((v) => runStatusTitle(v as TaskRunStatus)))}
removable={false}
/>
);
}
case "tags": {
const values = Array.isArray(value) ? value : [`${value}`];
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={filterTitle(key)}
icon={filterIcon(key)}
value={appliedSummary(values)}
removable={false}
/>
);
}
case "bulkId": {
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={filterTitle(key)}
icon={filterIcon(key)}
value={value}
removable={false}
/>
);
}
case "rootOnly": {
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={"Root only"}
icon={filterIcon(key)}
value={
value ? (
<CheckIcon className="size-4" />
) : (
<XCircleIcon className="size-4" />
)
}
removable={false}
/>
);
}
case "runId": {
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={"Run ID"}
icon={filterIcon(key)}
value={value}
removable={false}
/>
);
}
case "batchId": {
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={"Batch ID"}
icon={filterIcon(key)}
value={value}
removable={false}
/>
);
}
case "scheduleId": {
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={"Schedule ID"}
icon={filterIcon(key)}
value={value}
removable={false}
/>
);
}
default: {
assertNever(typedKey);
}
}
})}
</div>
</div>
);
}
}
}
function Action({ action }: { action: BulkActionAction }) {
switch (action) {
case "cancel":
return (
<span>
<XCircleIcon className="mb-0.5 inline-block size-4 text-error" />
<span className="ml-0.5 text-text-bright">Canceled</span>
</span>
);
case "replay":
return (
<span>
<ArrowPathIcon className="mb-0.5 inline-block size-4 text-blue-400" />
<span className="ml-0.5 text-text-bright">Replayed</span>
</span>
);
}
}
export function EstimatedCount({ count }: { count?: number }) {
if (typeof count === "number") {
return <>~{formatNumber(count)}</>;
}
return <SpinnerWhite className="mx-0.5 -mt-0.5 inline size-3" />;
}
+46 -40
View File
@@ -15,10 +15,40 @@ export const DirectionSchema = z.union([z.literal("forward"), z.literal("backwar
export type Direction = z.infer<typeof DirectionSchema>;
export function ListPagination({ list, className }: { list: List; className?: string }) {
const bothDisabled = !list.pagination.previous && !list.pagination.next;
return (
<div className={cn("flex items-center gap-1", className)}>
<div className={cn("flex items-center", className)}>
<PreviousButton cursor={list.pagination.previous} />
<NextButton cursor={list.pagination.next} />
<div
className={cn(
"order-2 h-6 w-px bg-charcoal-600 transition-colors peer-hover/next:bg-charcoal-550 peer-hover/prev:bg-charcoal-550",
bothDisabled && "opacity-30"
)}
/>
</div>
);
}
function PreviousButton({ cursor }: { cursor?: string }) {
const path = useCursorPath(cursor, "backward");
return (
<div className={cn("peer/prev order-1", !path && "pointer-events-none")}>
<LinkButton
to={path ?? "#"}
variant={"secondary/small"}
LeadingIcon={ChevronLeftIcon}
className={cn(
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
!path && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !path && e.preventDefault()}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={!path}
/>
</div>
);
}
@@ -27,45 +57,21 @@ function NextButton({ cursor }: { cursor?: string }) {
const path = useCursorPath(cursor, "forward");
return (
<LinkButton
to={path ?? "#"}
variant={"minimal/small"}
TrailingIcon={ChevronRightIcon}
trailingIconClassName="text-text-dimmed"
className={cn(
"flex items-center",
!path && "cursor-not-allowed opacity-50 group-hover/button:bg-transparent"
)}
onClick={(e) => !path && e.preventDefault()}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={!path}
>
Next
</LinkButton>
);
}
function PreviousButton({ cursor }: { cursor?: string }) {
const path = useCursorPath(cursor, "backward");
return (
<LinkButton
to={path ?? "#"}
variant={"minimal/small"}
LeadingIcon={ChevronLeftIcon}
leadingIconClassName="text-text-dimmed"
className={cn(
"flex items-center",
!path && "cursor-not-allowed opacity-50 group-hover/button:bg-transparent"
)}
onClick={(e) => !path && e.preventDefault()}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={!path}
>
Prev
</LinkButton>
<div className={cn("peer/next order-3", !path && "pointer-events-none")}>
<LinkButton
to={path ?? "#"}
variant={"secondary/small"}
TrailingIcon={ChevronRightIcon}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
!path && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !path && e.preventDefault()}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={!path}
/>
</div>
);
}
@@ -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<User, "email" | "admin"> & { isImpersonating: boolean };
export type SideMenuProject = Pick<
@@ -270,6 +273,13 @@ export function SideMenu({
</SideMenuSection>
<SideMenuSection title="Manage">
<SideMenuItem
name="Bulk actions"
icon={ListCheckedIcon}
activeIconColor="text-bulkActions"
to={v3BulkActionsPath(organization, project, environment)}
data-action="bulk actions"
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
@@ -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;
@@ -13,26 +14,36 @@ const AccordionItem = React.forwardRef<
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("group rounded border border-grid-bright transition-colors", className)}
className={cn("group rounded border border-grid-bright transition-colors", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
type AccordionTriggerProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
leadingIcon?: RenderIcon;
leadingIconClassName?: string;
};
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
AccordionTriggerProps
>(({ className, children, leadingIcon, leadingIconClassName, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between px-3 py-2 text-sm text-text-bright transition-all group-hover:bg-grid-bright [&[data-state=open]>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}
>
{children}
<div className="flex items-center gap-1">
{leadingIcon && (
<Icon icon={leadingIcon} className={cn("size-5 shrink-0", leadingIconClassName)} />
)}
<div className="pl-0.5">{children}</div>
</div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -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: "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,11 +30,12 @@ type AppliedFilterProps = {
};
export function AppliedFilter({
icon,
label,
value,
removable = true,
onRemove,
variant = "tertiary/small",
variant = "secondary/small",
className,
}: AppliedFilterProps) {
const variantClassName = variants[variant];
@@ -42,11 +48,14 @@ export function AppliedFilter({
className
)}
>
<div className="flex items-center gap-0.5">
<div className="text-text-dimmed">
<span>{label}</span>:
<div className="flex items-start gap-0.5 leading-4">
<div className="-mt-[0.5px] flex items-center gap-1">
{icon}
<div className="text-text-bright">
<span>{label}</span>:
</div>
</div>
<div className="text-text-bright">
<div className="text-text-dimmed">
<div>{value}</div>
</div>
</div>
@@ -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<HTMLDivElement> & {
@@ -19,36 +19,75 @@ export function PaginationControls({
}
return (
<nav className="flex items-center gap-0.5" aria-label="Pagination">
<LinkButton
to={pageUrl(location, currentPage - 1)}
variant="minimal/small"
LeadingIcon={ChevronLeftIcon}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={currentPage === 1}
className={currentPage > 1 ? "group" : ""}
>
Prev
</LinkButton>
<nav className="flex items-center gap-1" aria-label="Pagination">
{showPageNumbers ? (
<>
<LinkButton
to={pageUrl(location, currentPage - 1)}
variant="secondary/small"
LeadingIcon={ChevronLeftIcon}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={currentPage === 1}
className={cn("px-2", currentPage > 1 ? "group" : "")}
/>
{showPageNumbers
? calculatePageLinks(currentPage, totalPages).map((page, i) => (
{calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))
: null}
))}
<LinkButton
to={pageUrl(location, currentPage + 1)}
variant="minimal/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={currentPage === totalPages}
className={currentPage !== totalPages ? "group" : ""}
>
Next
</LinkButton>
<LinkButton
to={pageUrl(location, currentPage + 1)}
variant="secondary/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={currentPage === totalPages}
className={cn("px-2", currentPage !== totalPages ? "group" : "")}
/>
</>
) : (
<div className="flex items-center">
<div className={cn("peer/prev order-1", currentPage === 1 && "pointer-events-none")}>
<LinkButton
to={pageUrl(location, currentPage - 1)}
variant="secondary/small"
LeadingIcon={ChevronLeftIcon}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={currentPage === 1}
className={cn(
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
currentPage === 1 && "cursor-not-allowed opacity-50"
)}
/>
</div>
<div
className={cn(
"order-2 h-6 w-px bg-charcoal-600 transition-colors peer-hover/next:bg-charcoal-550 peer-hover/prev:bg-charcoal-550",
currentPage === 1 && currentPage === totalPages && "opacity-30"
)}
/>
<div
className={cn("peer/next order-3", currentPage === totalPages && "pointer-events-none")}
>
<LinkButton
to={pageUrl(location, currentPage + 1)}
variant="secondary/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={currentPage === totalPages}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
currentPage === totalPages && "cursor-not-allowed opacity-50"
)}
/>
</div>
</div>
)}
</nav>
);
}
@@ -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-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,8 +38,8 @@ 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",
"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",
icon: "w-8 h-8 mb-2",
@@ -77,12 +77,12 @@ export function RadioButtonCircle({
{checked && (
<div
className={cn(
"flex h-full w-full items-center justify-center bg-indigo-700",
"flex h-full w-full items-center justify-center border border-indigo-500 bg-indigo-600",
outerCircleClassName
)}
>
<Circle
className={cn("size-1.5 fill-text-bright text-text-bright", innerCircleClassName)}
className={cn("size-2 fill-text-bright text-text-bright", innerCircleClassName)}
/>
</div>
)}
@@ -131,12 +131,12 @@ export const RadioGroupItem = React.forwardRef<
>
<div
className={cn(
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus-custom group-data-[state=checked]:border-indigo-500 disabled:cursor-not-allowed disabled:opacity-50",
variation.inputPosition
)}
>
<RadioGroupPrimitive.Indicator className="flex h-full w-full items-center justify-center bg-indigo-700">
<Circle className="h-1.5 w-1.5 fill-white text-white" />
<RadioGroupPrimitive.Indicator className="flex h-full w-full items-center justify-center rounded-full bg-indigo-600">
<Circle className="size-2 fill-text-bright text-text-bright" />
</RadioGroupPrimitive.Indicator>
</div>
<div className={cn(icon ? "flex h-full flex-col justify-end" : "")}>
@@ -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 (
<div className="flex h-[1.375rem] flex-none cursor-default items-center gap-2 border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright">
<Ariakit.SelectHeading render={<>{render}</>} />
<Ariakit.SelectHeading render={render} />
</div>
);
}
@@ -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-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"),
},
medium: {
container:
"flex items-center gap-x-2 rounded-md hover:bg-tertiary py-1.5 px-2 transition focus-custom",
@@ -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 (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip>
<TooltipTrigger tabIndex={-1} className={cn("h-fit", buttonClassName)} asChild={asChild}>
<TooltipTrigger
tabIndex={-1}
className={cn("h-fit", buttonClassName)}
style={buttonStyle}
asChild={asChild}
>
{button}
</TooltipTrigger>
<TooltipContent
@@ -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 (
<SimpleTooltip
content={value}
button={
<span className={cn("flex h-6 items-center gap-1", className)}>
<CopyableText value={value.slice(-length)} copyValue={value} className="font-mono" />
</span>
}
asChild
disableHoverableContent
/>
);
}
@@ -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) {
<AppliedFilters />
{hasFilters && (
<Form className="h-6">
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
Clear all
</Button>
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
</Form>
)}
</div>
@@ -107,9 +112,9 @@ function FilterMenu(props: BatchFiltersProps) {
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"minimal/small"}
variant={"secondary/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
tooltipTitle={"Filter batches"}
>
Filter
</SelectTrigger>
@@ -276,10 +281,12 @@ function AppliedStatusFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -396,8 +403,10 @@ function AppliedBatchIdFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Batch ID"
icon={<Squares2X2Icon className="size-3.5" />}
value={batchId}
onRemove={() => del(["id", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -1,27 +1,30 @@
import { ArrowPathIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
import { 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,
labelClassName,
}: {
type: BulkActionType;
className?: string;
iconClassName?: string;
labelClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<BulkActionIcon type={type} className={cn("h-4 w-4", iconClassName)} />
<BulkActionLabel type={type} />
<BulkActionLabel type={type} className={labelClassName} />
</span>
);
}
export function BulkActionLabel({ type }: { type: BulkActionType }) {
return <span className={bulkActionClassName(type)}>{bulkActionTitle(type)}</span>;
export function BulkActionLabel({ type, className }: { type: BulkActionType; className?: string }) {
return <span className={cn("text-text-dimmed", className)}>{bulkActionTitle(type)}</span>;
}
export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) {
@@ -71,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 (
<span className={cn("flex items-center gap-1", className)}>
<BulkActionStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<BulkActionStatusLabel status={status} className={labelClassName} />
</span>
);
}
export function BulkActionStatusIcon({
status,
className,
}: {
status: BulkActionStatus;
className: string;
}) {
switch (status) {
case "PENDING":
return <Spinner className={cn("text-pending", className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn("text-success", className)} />;
case "ABORTED":
return <NoSymbolIcon className={cn("text-error", className)} />;
default: {
assertNever(status);
}
}
}
export function BulkActionStatusLabel({
status,
className,
}: {
status: BulkActionStatus;
className?: string;
}) {
switch (status) {
case "PENDING":
return <span className={cn("text-pending", className)}>In progress</span>;
case "COMPLETED":
return <span className={cn("text-success", className)}>Completed</span>;
case "ABORTED":
return <span className={cn("text-error", className)}>Aborted</span>;
default: {
assertNever(status);
}
}
}
+188 -42
View File
@@ -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";
@@ -43,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,
@@ -53,32 +55,49 @@ import {
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { cn } from "~/utils/cn";
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(),
@@ -90,6 +109,109 @@ export const TaskRunListSearchFilters = z.object({
});
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
export type TaskRunListSearchFilterKey = 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 filterIcon(filterKey: string): ReactNode | undefined {
switch (filterKey) {
case "cursor":
case "direction":
return undefined;
case "statuses":
return <StatusIcon className="size-4" />;
case "tasks":
return <TaskIcon className="size-4" />;
case "tags":
return <TagIcon className="size-4" />;
case "bulkId":
return <ListCheckedIcon className="size-4" />;
case "period":
return <CalendarIcon className="size-4" />;
case "from":
return <CalendarIcon className="size-4" />;
case "to":
return <CalendarIcon className="size-4" />;
case "rootOnly":
return <IconToggleLeft className="size-4" />;
case "batchId":
return <Squares2X2Icon className="size-4" />;
case "runId":
return <FingerPrintIcon className="size-4" />;
case "scheduleId":
return <ClockIcon className="size-4" />;
default:
return undefined;
}
}
export function getRunFiltersFromSearchParams(
searchParams: URLSearchParams
): TaskRunListSearchFilters {
const params = {
cursor: searchParams.get("cursor") ?? undefined,
direction: searchParams.get("direction") ?? undefined,
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").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,
runId: searchParams.get("runId") ?? undefined,
batchId: searchParams.get("batchId") ?? undefined,
scheduleId: searchParams.get("scheduleId") ?? undefined,
};
const parsed = TaskRunListSearchFilters.safeParse(params);
if (!parsed.success) {
return {};
}
return parsed.data;
}
type RunFiltersProps = {
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
@@ -97,6 +219,7 @@ type RunFiltersProps = {
id: string;
type: BulkActionType;
createdAt: Date;
name: string;
}[];
rootOnlyDefault: boolean;
hasFilters: boolean;
@@ -125,9 +248,7 @@ export function RunsFilters(props: RunFiltersProps) {
{searchParams.has("rootOnly") && (
<input type="hidden" name="rootOnly" value={searchParams.get("rootOnly") as string} />
)}
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
Clear all
</Button>
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
</Form>
)}
</div>
@@ -145,7 +266,7 @@ const filterTypes = [
{ name: "run", title: "Run ID", icon: <FingerPrintIcon className="size-4" /> },
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <ListChecks className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <ListCheckedIcon className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
@@ -162,7 +283,7 @@ function FilterMenu(props: RunFiltersProps) {
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"tertiary/small"}
variant={"secondary/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
@@ -334,7 +455,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;
}
@@ -346,8 +467,10 @@ function AppliedStatusFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={filterIcon("statuses")}
value={appliedSummary(statuses.map((v) => runStatusTitle(v as TaskRunStatus)))}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -421,7 +544,7 @@ function TasksDropdown({
function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTasks">) {
const { values, del } = useSearchParams();
if (values("tasks").length === 0) {
if (values("tasks").length === 0 || values("tasks").every((v) => v === "")) {
return null;
}
@@ -433,6 +556,7 @@ function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTas
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Task"
icon={filterIcon("tasks")}
value={appliedSummary(
values("tasks").map((v) => {
const task = possibleTasks.find((task) => task.slug === v);
@@ -440,6 +564,7 @@ function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTas
})
)}
onRemove={() => del(["tasks", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -485,7 +610,7 @@ function BulkActionsDropdown({
<SelectProvider value={value("bulkId")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
className="min-w-0 max-w-[min(320px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
@@ -498,11 +623,20 @@ function BulkActionsDropdown({
<ComboBox placeholder={"Filter by bulk action..."} value={searchValue} />
<SelectList>
<SelectItem value={""}>None</SelectItem>
{filtered.map((item, index) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex gap-3">
<BulkActionStatusCombo type={item.type} iconClassName="size-4" />
<DateTime date={item.createdAt} />
{filtered.map((item) => (
<SelectItem key={item.id} value={item.id} className="[&>div]:h-fit">
<div className="flex flex-col gap-1 py-1">
<Paragraph variant="small/bright" className="truncate">
{item.name}
</Paragraph>
<div className="flex gap-3">
<BulkActionTypeCombo
type={item.type}
iconClassName="size-4"
labelClassName="text-text-dimmed"
/>
<DateTime date={item.createdAt} />
</div>
</div>
</SelectItem>
))}
@@ -531,8 +665,10 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick<RunFiltersProps, "bulkAc
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Bulk action"
icon={filterIcon("bulkId")}
value={bulkId}
onRemove={() => del(["bulkId", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -562,12 +698,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<typeof tagsLoader>();
useEffect(() => {
@@ -581,7 +720,7 @@ function TagsDropdown({
const filtered = useMemo(() => {
let items: string[] = [];
if (searchValue === "") {
items = values("tags");
items = selected ?? [];
}
if (fetcher.data === undefined) {
@@ -594,7 +733,7 @@ function TagsDropdown({
}, [searchValue, fetcher.data]);
return (
<SelectProvider value={values("tags")} setValue={handleChange} virtualFocus={true}>
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
@@ -638,7 +777,7 @@ function AppliedTagsFilter() {
const tags = values("tags");
if (tags.length === 0) {
if (tags.length === 0 || tags.every((v) => v === "")) {
return null;
}
@@ -650,8 +789,10 @@ function AppliedTagsFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Tags"
icon={filterIcon("tags")}
value={appliedSummary(values("tags"))}
onRemove={() => del(["tags", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -678,10 +819,9 @@ function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
return (
<Switch
disabled={disabled}
variant="tertiary/small"
variant="secondary/small"
label="Root only"
checked={disabled ? false : rootOnly}
className="bg-tertiary transition hover:bg-charcoal-600"
onCheckedChange={(checked) => {
replace({
rootOnly: checked ? "true" : "false",
@@ -796,8 +936,10 @@ function AppliedRunIdFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Run ID"
icon={filterIcon("runId")}
value={runId}
onRemove={() => del(["runId", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -914,8 +1056,10 @@ function AppliedBatchIdFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Batch ID"
icon={filterIcon("batchId")}
value={batchId}
onRemove={() => del(["batchId", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -1032,8 +1176,10 @@ function AppliedScheduleIdFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Schedule ID"
icon={filterIcon("scheduleId")}
value={scheduleId}
onRemove={() => del(["scheduleId", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -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<RuntimeEnvironment, "type" | "id"> & {
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();
export function timeFilterRenderValues({
from,
to,
period,
}: {
from?: Date;
to?: Date;
period?: string;
}) {
const rangeType: TimeRangeType = from && to ? "range" : from ? "from" : to ? "to" : "period";
const { period, from, to } = timeFilters({
period: value("period"),
from: value("from"),
to: value("to"),
});
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 (
<FilterMenuProvider>
{() => (
<TimeDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter label={label} value={valueLabel} removable={false} />
<AppliedFilter
label={label}
icon={filterIcon("period")}
value={valueLabel}
removable={false}
variant="secondary/small"
/>
</Ariakit.Select>
}
period={period}
@@ -229,20 +265,23 @@ export function TimeDropdown({
setOpen(false);
}, [fromValue, toValue, replace]);
const handlePeriodClick = useCallback((period: string) => {
setFromValue(undefined);
setToValue(undefined);
const handlePeriodClick = useCallback(
(period: string) => {
replace({
period,
cursor: undefined,
direction: undefined,
from: undefined,
to: undefined,
});
replace({
period: period,
cursor: undefined,
direction: undefined,
from: undefined,
to: undefined,
});
setFromValue(undefined);
setToValue(undefined);
setOpen(false);
}, []);
setOpen(false);
},
[replace]
);
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
@@ -266,8 +305,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}
</Button>
@@ -307,11 +350,13 @@ export function TimeDropdown({
<div className="flex justify-between gap-1 border-t border-grid-bright pt-3">
<Button
variant="tertiary/small"
onClick={() => {
onClick={(e) => {
e.preventDefault();
setFromValue(from);
setToValue(to);
setOpen(false);
}}
type="button"
>
Cancel
</Button>
@@ -323,7 +368,11 @@ export function TimeDropdown({
enabledOnInputElements: true,
}}
disabled={!fromValue && !toValue}
onClick={() => apply()}
onClick={(e) => {
e.preventDefault();
apply();
}}
type="button"
>
Apply
</Button>
@@ -347,7 +396,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?
@@ -18,15 +18,16 @@ import { Header3 } from "~/components/primitives/Headers";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import {
type RunListAppliedFilters,
type RunListItem,
} from "~/presenters/v3/RunListPresenter.server";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
type NextRunListAppliedFilters,
type NextRunListItem,
} from "~/presenters/v3/NextRunListPresenter.server";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
import { DateTime } from "../../primitives/DateTime";
import { Paragraph } from "../../primitives/Paragraph";
@@ -51,16 +52,13 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { useEnvironment } from "~/hooks/useEnvironment";
import { CopyableText } from "~/components/primitives/CopyableText";
import { ClipboardField } from "~/components/primitives/ClipboardField";
type RunsTableProps = {
total: number;
hasFilters: boolean;
filters: RunListAppliedFilters;
filters: NextRunListAppliedFilters;
showJob?: boolean;
runs: RunListItem[];
runs: NextRunListItem[];
isLoading?: boolean;
allowSelection?: boolean;
variant?: TableVariant;
@@ -93,7 +91,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 +100,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 +116,9 @@ export function TaskRunsTable({
<TableHeaderCell className="pl-3 pr-0">
{runs.length > 0 && (
<Checkbox
checked={hasAll(runs.map((r) => 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 +295,9 @@ export function TaskRunsTable({
{allowSelection && (
<TableCell className="pl-3 pr-0">
<Checkbox
checked={has(run.id)}
checked={has(run.friendlyId)}
onChange={(element) => {
toggle(run.id);
toggle(run.friendlyId);
}}
ref={(r) => {
checkboxes.current[index + 1] = r;
@@ -309,20 +307,7 @@ export function TaskRunsTable({
</TableCell>
)}
<TableCell to={path} isTabbableCell>
<SimpleTooltip
content={run.friendlyId}
button={
<span className="flex h-6 items-center gap-1">
<CopyableText
value={run.friendlyId.slice(-8)}
copyValue={run.friendlyId}
className="font-mono"
/>
</span>
}
asChild
disableHoverableContent
/>
<TruncatedCopyableValue value={run.friendlyId} />
</TableCell>
<TableCell to={path}>
<span className="flex items-center gap-x-1">
@@ -423,7 +408,7 @@ export function TaskRunsTable({
);
}
function RunActionsCell({ run, path }: { run: RunListItem; path: string }) {
function RunActionsCell({ run, path }: { run: NextRunListItem; path: string }) {
const location = useLocation();
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
@@ -75,9 +75,7 @@ export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) {
<AppliedFilters />
{hasFilters && (
<Form className="h-6">
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
Clear all
</Button>
<Button variant="minimal/small" LeadingIcon={TrashIcon} tooltip="Clear all filters" />
</Form>
)}
</div>
@@ -109,7 +107,7 @@ function FilterMenu() {
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"minimal/small"}
variant={"secondary/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
@@ -285,10 +283,12 @@ function AppliedStatusFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
statuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -409,8 +409,10 @@ function AppliedTagsFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Tags"
icon={<TagIcon className="size-3.5" />}
value={appliedSummary(values("tags"))}
onRemove={() => del(["tags", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -527,8 +529,10 @@ function AppliedWaitpointIdFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="ID"
icon={<FingerPrintIcon className="size-3.5" />}
value={id}
onRemove={() => del(["id", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
@@ -594,7 +598,7 @@ function IdempotencyKeyDropdown({
<div className="flex flex-col gap-1">
<Label>Idempotency key</Label>
<Input
placeholder="waitpoint_"
placeholder=""
value={idempotencyKey ?? ""}
onChange={(e) => setIdempotencyKey(e.target.value)}
variant="small"
@@ -643,8 +647,10 @@ function AppliedIdempotencyKeyFilter() {
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Idempotency key"
icon={<ListChecks className="size-3.5" />}
value={idempotencyKey}
onRemove={() => del(["idempotencyKey", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
+6 -3
View File
@@ -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")]),
@@ -913,7 +912,7 @@ const EnvironmentSchema = z.object({
RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000),
// Clickhouse
CLICKHOUSE_URL: z.string().optional(),
CLICKHOUSE_URL: z.string(),
CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -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<typeof EnvironmentSchema>;
+41 -30
View File
@@ -7,40 +7,19 @@ type Values = Record<string, string | string[] | undefined>;
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);
}
}
},
[location, search]
);
const replace = useCallback(
(values: Values) => {
set(values);
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
const s = set(new URLSearchParams(location.search), values);
navigate(`${location.pathname}?${s.toString()}`, { replace: true });
},
[location, search]
[location, navigate]
);
const del = useCallback(
(keys: string | string[]) => {
const search = new URLSearchParams(location.search);
if (!Array.isArray(keys)) {
keys = [keys];
}
@@ -49,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;
@@ -61,22 +41,53 @@ 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]
);
return {
value,
values,
set,
replace,
del,
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;
}
@@ -0,0 +1,50 @@
import {
getRunFiltersFromSearchParams,
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 = getRunFiltersFromSearchParams(url.searchParams);
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,
};
}
@@ -7,12 +7,13 @@ import {
import { type Project, type RuntimeEnvironment, type TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { z } from "zod";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logger } from "~/services/logger.server";
import { CoercedDate } from "~/utils/zod";
import { ApiRetrieveRunPresenter } from "./ApiRetrieveRunPresenter.server";
import { type RunListOptions, RunListPresenter } from "./RunListPresenter.server";
import { BasePresenter } from "./basePresenter.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { ApiRetrieveRunPresenter } from "./ApiRetrieveRunPresenter.server";
import { NextRunListPresenter, type RunListOptions } from "./NextRunListPresenter.server";
import { BasePresenter } from "./basePresenter.server";
export const ApiRunListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
@@ -136,10 +137,12 @@ export class ApiRunListPresenter extends BasePresenter {
}
let environmentId: string | undefined;
let organizationId: string | undefined;
// filters
if (environment) {
environmentId = environment.id;
organizationId = environment.organizationId;
} else {
if (searchParams["filter[env]"]) {
const environments = await this._prisma.runtimeEnvironment.findMany({
@@ -152,6 +155,7 @@ export class ApiRunListPresenter extends BasePresenter {
});
environmentId = environments.at(0)?.id;
organizationId = environments.at(0)?.organizationId;
}
}
@@ -159,6 +163,10 @@ export class ApiRunListPresenter extends BasePresenter {
throw new ServiceValidationError("No environment found");
}
if (!organizationId) {
throw new ServiceValidationError("No organization found");
}
if (searchParams["filter[status]"]) {
options.statuses = searchParams["filter[status]"].flatMap((status) =>
ApiRunListPresenter.apiStatusToRunStatuses(status)
@@ -205,11 +213,11 @@ export class ApiRunListPresenter extends BasePresenter {
options.batchId = searchParams["filter[batch]"];
}
const presenter = new RunListPresenter();
const presenter = new NextRunListPresenter(this._prisma, clickhouseClient);
logger.debug("Calling RunListPresenter", { options });
const results = await presenter.call(environmentId, options);
const results = await presenter.call(organizationId, environmentId, options);
logger.debug("RunListPresenter results", { runs: results.runs.length });
@@ -0,0 +1,62 @@
import { getUsername } from "~/utils/username";
import { BasePresenter } from "./basePresenter.server";
type BulkActionListOptions = {
environmentId: string;
page?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type BulkActionListItem = Awaited<
ReturnType<BulkActionListPresenter["call"]>
>["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,
})),
};
}
}
@@ -0,0 +1,69 @@
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;
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,
successCount: true,
failureCount: true,
user: {
select: {
name: true,
displayName: true,
avatarUrl: true,
},
},
params: true,
project: {
select: {
id: true,
organizationId: true,
},
},
},
where: {
environmentId,
friendlyId: bulkActionId,
},
});
if (!bulkAction) {
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,
};
}
}
@@ -0,0 +1,46 @@
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)
);
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,
};
}
}
@@ -75,8 +75,6 @@ export class NextRunListPresenter {
to,
});
const periodMs = time.period ? parseDuration(time.period) : undefined;
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
@@ -96,15 +94,16 @@ 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,
type: true,
createdAt: true,
name: true,
},
where: {
projectId: projectId,
environmentId,
},
orderBy: {
createdAt: "desc",
@@ -118,71 +117,29 @@ export class NextRunListPresenter {
findDisplayableEnvironment(environmentId, userId),
]);
if (!displayableEnvironment) {
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({
// 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: {
items: {
select: {
destinationRunId: true,
},
},
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
friendlyId: bulkId,
projectId,
environmentId,
},
});
if (bulkAction) {
const runIds = bulkAction.items.map((item) => item.destinationRunId).filter(Boolean);
restrictToRunIds = runIds;
if (selectedBulkAction) {
bulkActions.push(selectedBulkAction);
}
}
//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;
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
const runsRepository = new RunsRepository({
@@ -204,14 +161,14 @@ 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,
bulkId,
page: {
size: pageSize,
cursor,
@@ -286,6 +243,7 @@ export class NextRunListPresenter {
id: bulkAction.friendlyId,
type: bulkAction.type,
createdAt: bulkAction.createdAt,
name: bulkAction.name || bulkAction.friendlyId,
})),
filters: {
tasks: tasks || [],
@@ -1,409 +0,0 @@
import { Prisma, type TaskRunStatus } from "@trigger.dev/database";
import parse from "parse-duration";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { sqlDatabaseSchema } from "~/db.server";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
export type RunListOptions = {
userId?: string;
projectId: 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[];
//pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
export type RunListItem = RunList["runs"][0];
export type RunListAppliedFilters = RunList["filters"];
export class RunListPresenter extends BasePresenter {
public async call(
environmentId: string,
{
userId,
projectId,
tasks,
versions,
statuses,
tags,
scheduleId,
period,
bulkId,
isTest,
rootOnly,
batchId,
runIds,
from,
to,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: RunListOptions
) {
//get the time values from the raw values (including a default period)
const time = timeFilters({
period,
from,
to,
});
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
(tasks !== undefined && tasks.length > 0) ||
(versions !== undefined && versions.length > 0) ||
hasStatusFilters ||
(bulkId !== undefined && bulkId !== "") ||
(scheduleId !== undefined && scheduleId !== "") ||
(tags !== undefined && tags.length > 0) ||
batchId !== undefined ||
(runIds !== undefined && runIds.length > 0) ||
typeof isTest === "boolean" ||
rootOnly === true ||
!time.isDefault;
//get all possible tasks
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,
type: true,
createdAt: true,
},
where: {
projectId: projectId,
},
orderBy: {
createdAt: "desc",
},
take: 20,
});
const [possibleTasks, bulkActions, displayableEnvironment] = await Promise.all([
possibleTasksAsync,
bulkActionsAsync,
findDisplayableEnvironment(environmentId, userId),
]);
if (!displayableEnvironment) {
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 periodMs = time.period ? parse(time.period) : undefined;
function clampToNow(date: Date): Date {
const now = new Date();
return date > now ? now : date;
}
//get the runs
const runs = await this._replica.$queryRaw<
{
id: string;
number: BigInt;
runFriendlyId: string;
taskIdentifier: string;
version: string | null;
status: TaskRunStatus;
createdAt: Date;
startedAt: Date | null;
lockedAt: Date | null;
delayUntil: Date | null;
updatedAt: Date;
completedAt: Date | null;
isTest: boolean;
spanId: string;
idempotencyKey: string | null;
ttl: string | null;
expiredAt: Date | null;
costInCents: number;
baseCostInCents: number;
usageDurationMs: BigInt;
tags: null | string[];
depth: number;
rootTaskRunId: string | null;
batchId: string | null;
metadata: string | null;
metadataType: string;
}[]
>`
SELECT
tr.id,
tr.number,
tr."friendlyId" AS "runFriendlyId",
tr."taskIdentifier" AS "taskIdentifier",
tr."taskVersion" AS version,
tr.status AS status,
tr."createdAt" AS "createdAt",
tr."startedAt" AS "startedAt",
tr."delayUntil" AS "delayUntil",
tr."lockedAt" AS "lockedAt",
tr."updatedAt" AS "updatedAt",
tr."completedAt" AS "completedAt",
tr."isTest" AS "isTest",
tr."spanId" AS "spanId",
tr."idempotencyKey" AS "idempotencyKey",
tr."ttl" AS "ttl",
tr."expiredAt" AS "expiredAt",
tr."baseCostInCents" AS "baseCostInCents",
tr."costInCents" AS "costInCents",
tr."usageDurationMs" AS "usageDurationMs",
tr."depth" AS "depth",
tr."rootTaskRunId" AS "rootTaskRunId",
tr."runTags" AS "tags",
tr."metadata" AS "metadata",
tr."metadataType" AS "metadataType"
FROM
${sqlDatabaseSchema}."TaskRun" tr
WHERE
-- project
tr."runtimeEnvironmentId" = ${environmentId}
-- cursor
${
cursor
? direction === "forward"
? Prisma.sql`AND tr.id < ${cursor}`
: Prisma.sql`AND tr.id > ${cursor}`
: Prisma.empty
}
-- filters
${runIds ? Prisma.sql`AND tr."friendlyId" IN (${Prisma.join(runIds)})` : Prisma.empty}
${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty}
${
restrictToRunIds
? restrictToRunIds.length === 0
? Prisma.sql`AND tr.id = ''`
: Prisma.sql`AND tr.id IN (${Prisma.join(restrictToRunIds)})`
: Prisma.empty
}
${
tasks && tasks.length > 0
? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})`
: Prisma.empty
}
${
statuses && statuses.length > 0
? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
: Prisma.empty
}
${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty}
${typeof isTest === "boolean" ? Prisma.sql`AND tr."isTest" = ${isTest}` : Prisma.empty}
${
periodMs
? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
: Prisma.empty
}
${
time.from
? Prisma.sql`AND tr."createdAt" >= ${time.from.toISOString()}::timestamp`
: Prisma.empty
}
${
time.to
? Prisma.sql`AND tr."createdAt" <= ${clampToNow(time.to).toISOString()}::timestamp`
: Prisma.sql`AND tr."createdAt" <= CURRENT_TIMESTAMP`
}
${
tags && tags.length > 0
? Prisma.sql`AND tr."runTags" && ARRAY[${Prisma.join(tags)}]::text[]`
: Prisma.empty
}
${rootOnly === true ? Prisma.sql`AND tr."rootTaskRunId" IS NULL` : Prisma.empty}
ORDER BY
${direction === "forward" ? Prisma.sql`tr.id DESC` : Prisma.sql`tr.id ASC`}
LIMIT ${pageSize + 1}`;
const hasMore = runs.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? runs.at(0)?.id : undefined;
if (hasMore) {
next = runs[pageSize - 1]?.id;
}
break;
case "backward":
runs.reverse();
if (hasMore) {
previous = runs[1]?.id;
next = runs[pageSize]?.id;
} else {
next = runs[pageSize - 1]?.id;
}
break;
}
const runsToReturn =
direction === "backward" && hasMore ? runs.slice(1, pageSize + 1) : runs.slice(0, pageSize);
let hasAnyRuns = runsToReturn.length > 0;
if (!hasAnyRuns) {
const firstRun = await this._replica.taskRun.findFirst({
where: {
runtimeEnvironmentId: environmentId,
},
});
if (firstRun) {
hasAnyRuns = true;
}
}
return {
runs: runsToReturn.map((run) => {
const hasFinished = isFinalRunStatus(run.status);
const startedAt = run.startedAt ?? run.lockedAt;
return {
id: run.id,
friendlyId: run.runFriendlyId,
number: Number(run.number),
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
startedAt: startedAt ? startedAt.toISOString() : undefined,
delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined,
hasFinished,
finishedAt: hasFinished
? run.completedAt?.toISOString() ?? run.updatedAt.toISOString()
: undefined,
isTest: run.isTest,
status: run.status,
version: run.version,
taskIdentifier: run.taskIdentifier,
spanId: run.spanId,
isReplayable: true,
isCancellable: isCancellableRunStatus(run.status),
isPending: isPendingRunStatus(run.status),
environment: displayableEnvironment,
idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined,
ttl: run.ttl ? run.ttl : undefined,
expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
usageDurationMs: Number(run.usageDurationMs),
tags: run.tags ? run.tags.sort((a, b) => a.localeCompare(b)) : [],
depth: run.depth,
rootTaskRunId: run.rootTaskRunId,
metadata: run.metadata,
metadataType: run.metadataType,
};
}),
pagination: {
next,
previous,
},
possibleTasks: possibleTasks
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
.sort((a, b) => {
return a.slug.localeCompare(b.slug);
}),
bulkActions: bulkActions.map((bulkAction) => ({
id: bulkAction.friendlyId,
type: bulkAction.type,
createdAt: bulkAction.createdAt,
})),
filters: {
tasks: tasks || [],
versions: versions || [],
statuses: statuses || [],
from: time.from,
to: time.to,
},
hasFilters,
hasAnyRuns,
};
}
}
@@ -11,7 +11,6 @@ import {
type CurrentRunningStats,
type DailyTaskActivity,
type EnvironmentMetricsRepository,
PostgrestEnvironmentMetricsRepository,
} from "~/services/environmentMetricsRepository.server";
import { singleton } from "~/utils/singleton";
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
@@ -110,13 +109,9 @@ export class TaskListPresenter {
export const taskListPresenter = singleton("taskListPresenter", setupTaskListPresenter);
function setupTaskListPresenter() {
const environmentMetricsRepository = clickhouseClient
? new ClickHouseEnvironmentMetricsRepository({
clickhouse: clickhouseClient,
})
: new PostgrestEnvironmentMetricsRepository({
prisma: $replica,
});
const environmentMetricsRepository = new ClickHouseEnvironmentMetricsRepository({
clickhouse: clickhouseClient,
});
return new TaskListPresenter(environmentMetricsRepository, $replica);
}
@@ -124,60 +124,24 @@ async function getTaskUsageByOrganization(
endOfMonth: Date,
replica: PrismaClientOrTransaction
) {
if (clickhouseClient) {
const [queryError, tasks] = await clickhouseClient.taskRuns.getTaskUsageByOrganization({
startTime: startOfMonth.getTime(),
endTime: endOfMonth.getTime(),
organizationId,
});
const [queryError, tasks] = await clickhouseClient.taskRuns.getTaskUsageByOrganization({
startTime: startOfMonth.getTime(),
endTime: endOfMonth.getTime(),
organizationId,
});
if (queryError) {
throw queryError;
}
return tasks
.map((task) => ({
taskIdentifier: task.task_identifier,
runCount: Number(task.run_count),
averageDuration: Number(task.average_duration),
averageCost: Number(task.average_cost) + env.CENTS_PER_RUN / 100,
totalDuration: Number(task.total_duration),
totalCost: Number(task.total_cost) + Number(task.total_base_cost),
}))
.sort((a, b) => b.totalCost - a.totalCost);
} else {
return replica.$queryRaw<TaskUsageItem[]>`
SELECT
tr."taskIdentifier",
COUNT(*) AS "runCount",
AVG(tr."usageDurationMs") AS "averageDuration",
SUM(tr."usageDurationMs") AS "totalDuration",
AVG(tr."costInCents") / 100.0 AS "averageCost",
SUM(tr."costInCents") / 100.0 AS "totalCost",
SUM(tr."baseCostInCents") / 100.0 AS "totalBaseCost"
FROM
${sqlDatabaseSchema}."TaskRun" tr
JOIN ${sqlDatabaseSchema}."Project" pr ON pr.id = tr."projectId"
JOIN ${sqlDatabaseSchema}."Organization" org ON org.id = pr."organizationId"
JOIN ${sqlDatabaseSchema}."RuntimeEnvironment" env ON env."id" = tr."runtimeEnvironmentId"
WHERE
env.type <> 'DEVELOPMENT'
AND tr."createdAt" > ${startOfMonth}
AND tr."createdAt" < ${endOfMonth}
AND org.id = ${organizationId}
GROUP BY
tr."taskIdentifier";
`.then((data) => {
return data
.map((item) => ({
taskIdentifier: item.taskIdentifier,
runCount: Number(item.runCount),
averageDuration: Number(item.averageDuration),
averageCost: Number(item.averageCost) + env.CENTS_PER_RUN / 100,
totalDuration: Number(item.totalDuration),
totalCost: Number(item.totalCost) + Number(item.totalBaseCost),
}))
.sort((a, b) => b.totalCost - a.totalCost);
});
if (queryError) {
throw queryError;
}
return tasks
.map((task) => ({
taskIdentifier: task.task_identifier,
runCount: Number(task.run_count),
averageDuration: Number(task.average_duration),
averageCost: Number(task.average_cost) + env.CENTS_PER_RUN / 100,
totalDuration: Number(task.total_duration),
totalCost: Number(task.total_cost) + Number(task.total_base_cost),
}))
.sort((a, b) => b.totalCost - a.totalCost);
}
@@ -1,8 +1,9 @@
import { ScheduleObject } from "@trigger.dev/core/v3";
import { PrismaClient, prisma } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
import { RunListPresenter } from "./RunListPresenter.server";
import { NextRunListPresenter } from "./NextRunListPresenter.server";
type ViewScheduleOptions = {
userId?: string;
@@ -34,6 +35,7 @@ export class ViewSchedulePresenter {
project: {
select: {
id: true,
organizationId: true,
},
},
instances: {
@@ -75,12 +77,12 @@ export class ViewSchedulePresenter {
? nextScheduledTimestamps(schedule.generatorExpression, schedule.timezone, new Date(), 5)
: [];
const runPresenter = new RunListPresenter(this.#prismaClient);
const { runs } = await runPresenter.call(environmentId, {
const runPresenter = new NextRunListPresenter(this.#prismaClient, clickhouseClient);
const { runs } = await runPresenter.call(schedule.project.organizationId, environmentId, {
projectId: schedule.project.id,
scheduleId: schedule.id,
pageSize: 5,
period: "31d",
});
return {
@@ -1,8 +1,9 @@
import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { type RunListItem, RunListPresenter } from "./RunListPresenter.server";
import { NextRunListPresenter, type NextRunListItem } from "./NextRunListPresenter.server";
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
export type WaitpointDetail = NonNullable<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
@@ -47,6 +48,7 @@ export class WaitpointPresenter extends BasePresenter {
environment: {
select: {
apiKey: true,
organizationId: true,
},
},
},
@@ -74,15 +76,21 @@ export class WaitpointPresenter extends BasePresenter {
}
const connectedRunIds = waitpoint.connectedRuns.map((run) => run.friendlyId);
const connectedRuns: RunListItem[] = [];
const connectedRuns: NextRunListItem[] = [];
if (connectedRunIds.length > 0) {
const runPresenter = new RunListPresenter();
const { runs } = await runPresenter.call(environmentId, {
projectId: projectId,
runIds: connectedRunIds,
pageSize: 5,
});
const runPresenter = new NextRunListPresenter(this._prisma, clickhouseClient);
const { runs } = await runPresenter.call(
waitpoint.environment.organizationId,
environmentId,
{
projectId: projectId,
runIds: connectedRunIds,
pageSize: 5,
period: "31d",
}
);
connectedRuns.push(...runs);
}
@@ -0,0 +1,345 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form, useRevalidator } from "@remix-run/react";
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";
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 { 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 { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
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,
v3BulkActionsPath,
v3CreateBulkActionPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
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 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<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const disabled = bulkAction.status !== BulkActionStatus.PENDING;
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.id}/runs/bulkaction/${bulkAction.friendlyId}/stream`,
{
event: "progress",
disabled,
}
);
const revalidation = useRevalidator();
useEffect(() => {
if (disabled || streamedEvents === null) {
return;
}
revalidation.revalidate();
}, [streamedEvents, disabled]);
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_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={cn("whitespace-nowrap")}>
{bulkAction.name || bulkAction.friendlyId}
</Header2>
<LinkButton
to={v3BulkActionsPath(organization, project, environment)}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
<BulkActionStatusCombo status={bulkAction.status} />
{bulkAction.status === "PENDING" ? (
<Form method="post">
<Button type="submit" variant="danger/small">
Abort bulk action
</Button>
</Form>
) : null}
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="space-y-3">
<div className="px-3 pt-3">
<Meter
type={bulkAction.type}
successCount={bulkAction.successCount}
failureCount={bulkAction.failureCount}
totalCount={bulkAction.totalCount}
/>
</div>
<div className="px-3 pb-3">
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>
<CopyableText value={bulkAction.friendlyId} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Bulk action</Property.Label>
<Property.Value>
<BulkActionTypeCombo type={bulkAction.type} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>User</Property.Label>
<Property.Value>
{bulkAction.user ? (
<div className="flex items-center gap-1">
<UserAvatar
name={bulkAction.user.name}
avatarUrl={bulkAction.user.avatarUrl}
className="h-4 w-4"
/>
<Paragraph variant="extra-small">{bulkAction.user.name}</Paragraph>
</div>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={bulkAction.createdAt} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Completed</Property.Label>
<Property.Value>
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Summary</Property.Label>
<Property.Value>
<BulkActionFilterSummary
selected={bulkAction.totalCount}
mode={bulkAction.mode}
action={bulkAction.type === BulkActionType.REPLAY ? "replay" : "cancel"}
filters={bulkAction.filters}
final={true}
/>
</Property.Value>
</Property.Item>
</Property.Table>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<LinkButton
to={v3CreateBulkActionPath(
organization,
project,
environment,
{
bulkId: bulkAction.friendlyId,
},
undefined,
"replay"
)}
variant="tertiary/medium"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-indigo-500"
>
Replay runs
</LinkButton>
<LinkButton
variant="tertiary/medium"
to={v3RunsPath(organization, project, environment, {
bulkId: bulkAction.friendlyId,
})}
LeadingIcon={RunsIcon}
leadingIconClassName="text-indigo-500"
>
View runs
</LinkButton>
</div>
</div>
);
}
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 (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Paragraph variant="small/bright">Runs</Paragraph>
<Paragraph variant="extra-small">
{formatNumber(successCount + failureCount)}/{formatNumber(totalCount)}
</Paragraph>
</div>
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-charcoal-900">
<motion.div
className="absolute left-0 top-0 h-full w-full bg-success"
initial={{ width: `${successPercentage}%` }}
animate={{ width: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
<motion.div
className="absolute top-0 h-full w-full bg-charcoal-550"
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-success" />
<Paragraph variant="extra-small">
{formatNumber(successCount)} {typeText(type)} successfully
</Paragraph>
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-charcoal-550" />
<Paragraph variant="extra-small">
{formatNumber(failureCount)} {typeText(type)} failed{" "}
{type === BulkActionType.CANCEL ? " (already finished)" : ""}
</Paragraph>
</div>
</div>
</div>
);
}
function typeText(type: BulkActionType) {
switch (type) {
case BulkActionType.CANCEL:
return "canceled";
case BulkActionType.REPLAY:
return "replayed";
}
}
@@ -0,0 +1,297 @@
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 { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { BulkActionsNone } from "~/components/BlankStatePanels";
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 { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
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 {
type BulkActionListItem,
BulkActionListPresenter,
} from "~/presenters/v3/BulkActionListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
docsPath,
EnvironmentParamSchema,
v3BulkActionPath,
v3CreateBulkActionPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Bulk actions | Trigger.dev`,
},
];
};
const SearchParamsSchema = z.object({
page: z.coerce.number().optional(),
});
export const loader = async ({ 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 });
}
try {
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, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const { bulkActions, currentPage, totalPages, totalCount } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { bulkActionParam } = useParams();
const isShowingInspector = bulkActionParam !== undefined;
return (
<PageContainer>
<NavBar>
<PageTitle title="Bulk actions" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/bulk-actions")}
>
Bulk actions docs
</LinkButton>
<LinkButton
variant="primary/small"
LeadingIcon={PlusIcon}
to={v3CreateBulkActionPath(organization, project, environment)}
shortcut={{
key: "n",
}}
>
New bulk action
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{bulkActions.length === 0 ? (
<MainCenteredContainer className="max-w-prose">
<BulkActionsNone />
</MainCenteredContainer>
) : (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="bulk-actions-main" min={"100px"}>
<div
className={cn(
"grid max-h-full min-h-full overflow-x-auto",
totalPages > 1 ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[1fr]"
)}
>
{totalPages > 1 && (
<div className="flex items-center justify-end gap-x-2 p-2">
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
)}
<BulkActionsTable bulkActions={bulkActions} totalPages={totalPages} />
{totalPages > 1 && (
<div
className={cn(
"flex min-h-full",
totalPages > 1 && "justify-end border-t border-grid-dimmed px-2 py-3"
)}
>
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
)}
</div>
</ResizablePanel>
{isShowingInspector && (
<>
<ResizableHandle id="bulk-actions-handle" />
<ResizablePanel id="bulk-actions-inspector" min="100px" default="500px">
<Outlet />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
)}
</PageBody>
</PageContainer>
);
}
function BulkActionsTable({
bulkActions,
totalPages,
}: {
bulkActions: BulkActionListItem[];
totalPages: number;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { bulkActionParam } = useParams();
return (
<Table containerClassName={cn(totalPages === 1 && "border-t-0")}>
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BulkActionStatusCombo status="PENDING" />
</div>
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
The bulk action is currently in progress. They can take some time if there are
lots of runs.
</Paragraph>
</div>
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BulkActionStatusCombo status="COMPLETED" />
</div>
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
The bulk action has completed successfully.
</Paragraph>
</div>
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BulkActionStatusCombo status="ABORTED" />
</div>
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
The bulk action was aborted.
</Paragraph>
</div>
</div>
}
>
Status
</TableHeaderCell>
<TableHeaderCell>Bulk action</TableHeaderCell>
<TableHeaderCell>Runs</TableHeaderCell>
<TableHeaderCell>User</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Completed</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{bulkActions.length === 0 ? (
<TableBlankRow colSpan={8}>There are no matching bulk actions</TableBlankRow>
) : (
bulkActions.map((bulkAction) => {
const path = v3BulkActionPath(organization, project, environment, bulkAction);
const isSelected = bulkActionParam === bulkAction.friendlyId;
return (
<TableRow
key={bulkAction.friendlyId}
className={isSelected ? "bg-grid-dimmed" : undefined}
>
<TableCell to={path} isTabbableCell>
<TruncatedCopyableValue value={bulkAction.friendlyId} />
</TableCell>
<TableCell to={path}>{bulkAction.name || ""}</TableCell>
<TableCell to={path}>
<BulkActionStatusCombo status={bulkAction.status} />
</TableCell>
<TableCell to={path}>
<BulkActionTypeCombo type={bulkAction.type} />
</TableCell>
<TableCell to={path}>{bulkAction.totalCount}</TableCell>
<TableCell to={path}>
{bulkAction.user ? (
<div className="flex items-center gap-1">
<UserAvatar
name={bulkAction.user.name}
avatarUrl={bulkAction.user.avatarUrl}
className="h-4 w-4"
/>
<Paragraph variant="extra-small">{bulkAction.user.name}</Paragraph>
</div>
) : (
""
)}
</TableCell>
<TableCell to={path}>
<DateTime date={bulkAction.createdAt} />
</TableCell>
<TableCell to={path}>
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : ""}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
);
}
@@ -1,511 +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, 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";
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,
v3ProjectPath,
v3RunsNextPath,
v3TestPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
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 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");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
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 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,
});
const session = await setRootOnlyFilterPreference(rootOnlyValue, request);
const cookieValue = await uiPreferencesStorage.commitSession(session);
return typeddefer(
{
data: list,
rootOnlyDefault: rootOnlyValue,
},
{
headers: {
"Set-Cookie": cookieValue,
},
}
);
};
export default function Page() {
const { data, rootOnlyDefault } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const { isConnected } = useDevPresence();
const project = useProject();
const environment = useEnvironment();
return (
<>
<NavBar>
<PageTitle title="Runs" />
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<DevDisconnectedBanner isConnected={isConnected} />
)}
<PageAccessories>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/runs-and-attempts")}
>
Runs docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<SelectedItemsProvider
initialSelectedItems={[]}
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>
)}
</>
)}
</TypedAwait>
</Suspense>
<BulkActionBar />
</div>
)}
</SelectedItemsProvider>
</PageBody>
</>
);
}
function BulkActionBar() {
const { selectedItems, deselectAll } = useSelectedItems();
const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none");
const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT;
return (
<AnimatePresence>
{selectedItems.size > 0 && (
<motion.div
initial={{ translateY: "100%" }}
animate={{ translateY: 0 }}
exit={{ translateY: "100%" }}
className="flex items-center justify-between gap-3 border-t border-grid-bright bg-background-bright py-3 pl-4 pr-3"
>
<div className="flex items-center gap-1.5 text-sm text-text-bright">
<ListChecks className="mr-1 size-7 text-indigo-400" />
<Header2>Bulk actions:</Header2>
{hasSelectedMaximum ? (
<Paragraph className="text-warning">
Maximum of {selectedItems.size} runs selected
</Paragraph>
) : (
<Paragraph className="">{selectedItems.size} runs selected</Paragraph>
)}
</div>
<div className="flex items-center gap-3">
<CancelRuns
onOpen={(o) => {
if (o) {
setBarState("cancel");
} else {
setBarState("none");
}
}}
/>
<ReplayRuns
onOpen={(o) => {
if (o) {
setBarState("replay");
} else {
setBarState("none");
}
}}
/>
<Button
variant="tertiary/medium"
shortcut={{ key: "esc", enabledOnInputElements: true }}
onClick={() => {
if (barState !== "none") return;
deselectAll();
}}
LeadingIcon={ListX}
leadingIconClassName="text-indigo-400 w-6 h-6"
>
Clear selection
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
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 (
<Dialog onOpenChange={(o) => onOpen(o)}>
<DialogTrigger asChild>
<Button
type="button"
variant="tertiary/medium"
shortcut={{ key: "c", enabledOnInputElements: true }}
LeadingIcon={IconCircleX}
leadingIconClassName="text-error w-[1.3rem] h-[1.3rem]"
>
Cancel runs
</Button>
</DialogTrigger>
<DialogContent key="replay">
<DialogHeader>Cancel {selectedItems.size} runs?</DialogHeader>
<DialogDescription className="pt-2">
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.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post" reloadDocument>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<input type="hidden" name="organizationSlug" value={organization.slug} />
<input type="hidden" name="projectSlug" value={project.slug} />
<input type="hidden" name="environmentSlug" value={environment.slug} />
{[...selectedItems].map((runId) => (
<input key={runId} type="hidden" name="runIds" value={runId} />
))}
<Button
type="submit"
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : StopCircleIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Canceling..." : `Cancel ${selectedItems.size} runs`}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog onOpenChange={(o) => onOpen(o)}>
<DialogTrigger asChild>
<Button
type="button"
variant="tertiary/medium"
shortcut={{ key: "r", enabledOnInputElements: true }}
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-blue-400 w-[1.3rem] h-[1.3rem]"
>
<span className="text-text-bright">Replay {selectedItems.size} runs</span>
</Button>
</DialogTrigger>
<DialogContent key="replay">
<DialogHeader>Replay runs?</DialogHeader>
<DialogDescription className="pt-2">
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.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post" reloadDocument>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<input type="hidden" name="organizationSlug" value={organization.slug} />
<input type="hidden" name="projectSlug" value={project.slug} />
<input type="hidden" name="environmentSlug" value={environment.slug} />
{[...selectedItems].map((runId) => (
<input key={runId} type="hidden" name="runIds" value={runId} />
))}
<Button
type="submit"
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Replaying..." : `Replay ${selectedItems.size} runs`}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CreateFirstTaskInstructions() {
const organization = useOrganization();
const project = useProject();
return (
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={TaskIcon}
iconClassName="text-blue-500"
panelClassName="max-full"
title="Create your first task"
accessory={
<LinkButton to={v3ProjectPath(organization, project)} variant="primary/small">
Create a task
</LinkButton>
}
>
<Paragraph variant="small">
Before running a task, you must first create one. Follow the instructions on the{" "}
<TextLink to={v3ProjectPath(organization, project)}>Tasks</TextLink> page to create a
task, then return here to run it.
</Paragraph>
</InfoPanel>
</MainCenteredContainer>
);
}
function RunTaskInstructions() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<MainCenteredContainer className="max-w-prose">
<Header1 className="mb-6 border-b py-2">How to run your tasks</Header1>
<StepNumber stepNumber="A" title="Trigger a test run" />
<StepContentContainer>
<Paragraph spacing>
Perform a test run with a payload directly from the dashboard.
</Paragraph>
<LinkButton
to={v3TestPath(organization, project, environment)}
variant="secondary/medium"
LeadingIcon={BeakerIcon}
leadingIconClassName="text-lime-500"
className="inline-flex"
>
Test
</LinkButton>
<div className="mt-6 flex items-center gap-2">
<hr className="w-full" />
<Paragraph variant="extra-extra-small/dimmed/caps">OR</Paragraph>
<hr className="w-full" />
</div>
</StepContentContainer>
<StepNumber stepNumber="B" title="Trigger your task for real" />
<StepContentContainer>
<Paragraph spacing>
Performing a real run depends on the type of trigger your task is using.
</Paragraph>
<LinkButton
to={docsPath("/triggering")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
How to trigger a task
</LinkButton>
</StepContentContainer>
</MainCenteredContainer>
);
}
@@ -1,10 +0,0 @@
import { Outlet } from "@remix-run/react";
import { PageContainer } from "~/components/layout/AppLayout";
export default function Page() {
return (
<PageContainer>
<Outlet />
</PageContainer>
);
}
@@ -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={<CopyableText value={run.friendlyId} />}
/>
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
<PageAccessories>
@@ -1,47 +1,49 @@
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 { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { Suspense } from "react";
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";
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 { Badge } from "~/components/primitives/Badge";
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";
import {
SelectedItemsProvider,
useSelectedItems,
} from "~/components/primitives/SelectedItemsProvider";
import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner";
ResizableHandle,
ResizablePanel,
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, TaskRunListSearchFilters } 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";
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 { RunListPresenter } from "~/presenters/v3/RunListPresenter.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";
@@ -50,11 +52,12 @@ import { cn } from "~/utils/cn";
import {
docsPath,
EnvironmentParamSchema,
v3CreateBulkActionPath,
v3ProjectPath,
v3RunsPath,
v3TestPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
export const meta: MetaFunction = () => {
return [
@@ -68,15 +71,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 +81,23 @@ 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);
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,9 +108,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function Page() {
const { data, rootOnlyDefault } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const { data, rootOnlyDefault, filters } = useTypedLoaderData<typeof loader>();
const { isConnected } = useDevPresence();
const project = useProject();
const environment = useEnvironment();
@@ -188,65 +136,32 @@ 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">
<Suspense
fallback={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto] overflow-hidden">
<div className="border-b border-grid-bright" />
<div className="my-2 flex items-center justify-center">
<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>
</div>
}
>
<TypedAwait resolve={data}>
{(list) => {
return (
<RunsList
list={list}
selectedItems={selectedItems}
rootOnlyDefault={rootOnlyDefault}
filters={filters}
/>
);
}}
</TypedAwait>
</Suspense>
)}
</SelectedItemsProvider>
</PageBody>
@@ -254,181 +169,136 @@ export default function Page() {
);
}
function BulkActionBar() {
const { selectedItems, deselectAll } = useSelectedItems();
const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none");
function RunsList({
list,
selectedItems,
rootOnlyDefault,
filters,
}: {
list: Awaited<UseDataFunctionReturn<typeof loader>["data"]>;
selectedItems: Set<string>;
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();
const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT;
// 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 (
<AnimatePresence>
{selectedItems.size > 0 && (
<motion.div
initial={{ translateY: "100%" }}
animate={{ translateY: 0 }}
exit={{ translateY: "100%" }}
className="flex items-center justify-between gap-3 border-t border-grid-bright bg-background-bright py-3 pl-4 pr-3"
<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]"
)}
>
<div className="flex items-center gap-1.5 text-sm text-text-bright">
<ListChecks className="mr-1 size-7 text-indigo-400" />
<Header2>Bulk actions:</Header2>
{hasSelectedMaximum ? (
<Paragraph className="text-warning">
Maximum of {selectedItems.size} runs selected
</Paragraph>
<>
{list.runs.length === 0 && !list.hasAnyRuns ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
) : (
<Paragraph className="">{selectedItems.size} runs selected</Paragraph>
<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">
{!isShowingBulkActionInspector && (
<LinkButton
variant="secondary/small"
to={v3CreateBulkActionPath(
organization,
project,
environment,
filters,
selectedItems.size > 0 ? "selected" : undefined
)}
LeadingIcon={ListCheckedIcon}
className={selectedItems.size > 0 ? "pr-1" : undefined}
tooltip={
<div className="-mr-1 flex items-center gap-3 text-xs text-text-dimmed">
<div className="flex items-center gap-0.5">
<span>Replay</span>
<ShortcutKey shortcut={{ key: "r" }} variant={"small"} />
</div>
<div className="flex items-center gap-0.5">
<span>Cancel</span>
<ShortcutKey shortcut={{ key: "c" }} variant={"small"} />
</div>
</div>
}
>
<span className="flex items-center gap-x-1 whitespace-nowrap text-text-bright">
<span>Bulk action</span>
{selectedItems.size > 0 && (
<Badge variant="rounded">{selectedItems.size}</Badge>
)}
</span>
</LinkButton>
)}
<ListPagination list={list} />
</div>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
allowSelection
/>
</div>
)}
</div>
<div className="flex items-center gap-3">
<CancelRuns
onOpen={(o) => {
if (o) {
setBarState("cancel");
} else {
setBarState("none");
}
}}
</>
</div>
</ResizablePanel>
{isShowingBulkActionInspector && (
<>
<ResizableHandle id="runs-handle" />
<ResizablePanel id="bulk-action-inspector" min="300px" default="400px" max="600px">
<CreateBulkActionInspector
filters={filters}
selectedItems={selectedItems}
hasBulkActions={list.bulkActions.length > 0}
/>
<ReplayRuns
onOpen={(o) => {
if (o) {
setBarState("replay");
} else {
setBarState("none");
}
}}
/>
<Button
variant="tertiary/medium"
shortcut={{ key: "esc", enabledOnInputElements: true }}
onClick={() => {
if (barState !== "none") return;
deselectAll();
}}
LeadingIcon={ListX}
leadingIconClassName="text-indigo-400 w-6 h-6"
>
Clear selection
</Button>
</div>
</motion.div>
</ResizablePanel>
</>
)}
</AnimatePresence>
);
}
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 (
<Dialog onOpenChange={(o) => onOpen(o)}>
<DialogTrigger asChild>
<Button
type="button"
variant="tertiary/medium"
shortcut={{ key: "c", enabledOnInputElements: true }}
LeadingIcon={IconCircleX}
leadingIconClassName="text-error w-[1.3rem] h-[1.3rem]"
>
Cancel runs
</Button>
</DialogTrigger>
<DialogContent key="replay">
<DialogHeader>Cancel {selectedItems.size} runs?</DialogHeader>
<DialogDescription className="pt-2">
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.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post" reloadDocument>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<input type="hidden" name="organizationSlug" value={organization.slug} />
<input type="hidden" name="projectSlug" value={project.slug} />
<input type="hidden" name="environmentSlug" value={environment.slug} />
{[...selectedItems].map((runId) => (
<input key={runId} type="hidden" name="runIds" value={runId} />
))}
<Button
type="submit"
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : StopCircleIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Canceling..." : `Cancel ${selectedItems.size} runs`}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog onOpenChange={(o) => onOpen(o)}>
<DialogTrigger asChild>
<Button
type="button"
variant="tertiary/medium"
shortcut={{ key: "r", enabledOnInputElements: true }}
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-blue-400 w-[1.3rem] h-[1.3rem]"
>
<span className="text-text-bright">Replay {selectedItems.size} runs</span>
</Button>
</DialogTrigger>
<DialogContent key="replay">
<DialogHeader>Replay runs?</DialogHeader>
<DialogDescription className="pt-2">
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.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post" reloadDocument>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<input type="hidden" name="organizationSlug" value={organization.slug} />
<input type="hidden" name="projectSlug" value={project.slug} />
<input type="hidden" name="environmentSlug" value={environment.slug} />
{[...selectedItems].map((runId) => (
<input key={runId} type="hidden" name="runIds" value={runId} />
))}
<Button
type="submit"
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Replaying..." : `Replay ${selectedItems.size} runs`}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
</ResizablePanelGroup>
);
}
@@ -257,7 +257,6 @@ function CreatePersonalAccessToken() {
defaultValue=""
icon={ShieldCheckIcon}
autoComplete="off"
data-1p-ignore
/>
<Hint>
This will help you to identify your token. Tokens called "cli" are automatically
@@ -0,0 +1,73 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { type TaskRun } from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { runsReplicationInstance } from "~/services/runsReplicationInstance.server";
import { FINAL_RUN_STATUSES } from "~/v3/taskStatus";
const Body = z.object({
runIds: z.array(z.string()),
});
const MAX_BATCH_SIZE = 50;
export async function action({ request }: ActionFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!user.admin) {
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
}
try {
const body = await request.json();
const { runIds } = Body.parse(body);
logger.info("Backfilling runs", { runIds });
const runs: TaskRun[] = [];
for (let i = 0; i < runIds.length; i += MAX_BATCH_SIZE) {
const batch = runIds.slice(i, i + MAX_BATCH_SIZE);
const batchRuns = await prisma.taskRun.findMany({
where: {
id: { in: batch },
status: {
in: FINAL_RUN_STATUSES,
},
},
});
runs.push(...batchRuns);
}
if (!runsReplicationInstance) {
throw new Error("Runs replication instance not found");
}
await runsReplicationInstance.backfill(runs);
logger.info("Backfilled runs", { runs });
return json({
success: true,
runCount: runs.length,
});
} catch (error) {
return json({ error: error instanceof Error ? error.message : error }, { status: 400 });
}
}
@@ -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);
},
};
},
});
@@ -0,0 +1,473 @@
import { parse } from "@conform-to/zod";
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";
import { tryCatch } from "@trigger.dev/core";
import { type TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { useEffect, useState } from "react";
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 {
BulkActionAction,
BulkActionFilterSummary,
BulkActionMode,
EstimatedCount,
} from "~/components/BulkActionFilterSummary";
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";
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";
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 {
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 { 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 { 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, v3BulkActionPath, v3RunsPath } from "~/utils/pathBuilder";
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
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);
}
export const CreateBulkActionSearchParams = z.object({
mode: BulkActionMode.default("filter"),
action: BulkActionAction.default("cancel"),
});
export const CreateBulkActionPayload = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("selected"),
action: BulkActionAction,
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()),
}),
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<typeof CreateBulkActionPayload>;
export async function action({ params, request }: ActionFunctionArgs) {
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 formData = await request.formData();
const submission = parse(formData, { schema: CreateBulkActionPayload });
if (!submission.value) {
logger.error("Invalid bulk action", {
submission,
formData: Object.fromEntries(formData),
});
return redirectWithErrorMessage("/", request, "Invalid bulk action");
}
const service = new BulkActionService();
const [error, result] = await tryCatch(
service.create(
project.organizationId,
project.id,
environment.id,
userId,
submission.value,
request
)
);
if (error) {
logger.error("Failed to create bulk action", {
error,
});
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
`Failed to create bulk action: ${error.message}`
);
}
return redirectWithSuccessMessage(
v3BulkActionPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: result.bulkActionId }
),
request,
"Bulk action started"
);
}
export function CreateBulkActionInspector({
filters,
selectedItems,
hasBulkActions,
}: {
filters: TaskRunListSearchFilters;
selectedItems: Set<string>;
hasBulkActions: boolean;
}) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof loader>();
const { value, replace } = useSearchParams();
const [action, setAction] = useState<BulkActionAction>(
bulkActionActionFromString(value("action"))
);
const location = useOptimisticLocation();
const user = useUser();
useEffect(() => {
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/bulkaction${location.search}`
);
}, [organization.id, project.id, environment.id, location.search]);
useEffect(() => {
setAction(bulkActionActionFromString(value("action")));
}, [value("action")]);
const mode = bulkActionModeFromString(value("mode"));
const data = fetcher.data != null ? fetcher.data : undefined;
const closedSearchParams = new URLSearchParams(location.search);
closedSearchParams.delete("bulkInspector");
const impactedCountElement =
mode === "selected" ? selectedItems.size : <EstimatedCount count={data?.count} />;
return (
<Form
method="post"
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/bulkaction${location.search}`}
className="h-full"
id="bulk-action-form"
>
<input type="hidden" name="failedRedirect" value={`${location.pathname}${location.search}`} />
<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
)}?${closedSearchParams.toString()}`}
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">
<div className="px-3 pt-3">
<Accordion
type="single"
collapsible
defaultValue={!hasBulkActions ? "instructions" : undefined}
>
<AccordionItem value="instructions">
<AccordionTrigger
leadingIcon={InformationCircleIcon}
leadingIconClassName="text-blue-500"
>
How to create a bulk action
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-2">
<Paragraph variant="small">
Select runs individually using the checkboxes.
</Paragraph>
<div>
<img src={selectRunsIndividually} alt="Select runs individually" />
</div>
<Paragraph variant="small">
Or select runs using the filter menus on this page.
</Paragraph>
<div>
<img src={selectRunsUsingFilters} alt="Select runs using filters" />
</div>
<Paragraph variant="small">
Then complete the form below and click Cancel runs or Replay runs.
</Paragraph>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<Fieldset className="p-3">
{Array.from(selectedItems).map((runId) => {
return <input key={runId} type="hidden" name="selectedRunIds" value={runId} />;
})}
<InputGroup>
<Label htmlFor="mode">Select</Label>
<RadioGroup
name="mode"
className="flex flex-col items-start gap-2"
defaultValue={mode}
onValueChange={(value) => {
replace({ mode: value });
}}
>
<RadioGroupItem
id="mode-filter"
label={
<span>
{data?.count === 0 ? "" : "All"} <EstimatedCount count={data?.count} /> runs
matching your filters
</span>
}
value={"filter"}
variant="button/small"
/>
<RadioGroupItem
id="mode-selected"
label={simplur`${selectedItems.size} individually selected run[|s]`}
value={"selected"}
variant="button/small"
className="grow"
/>
</RadioGroup>
</InputGroup>
<InputGroup>
<Label htmlFor="title">Name</Label>
<Input name="title" placeholder="A name for this bulk action" autoComplete="off" />
<Hint>Add a name to identify this bulk action (optional).</Hint>
</InputGroup>
<InputGroup>
<Label htmlFor="action">Bulk action to perform</Label>
<RadioGroup
name="action"
className="flex flex-col items-start gap-2"
value={action}
onValueChange={(value) => {
replace({ action: value });
}}
>
<RadioGroupItem
id="action-replay"
label={
<span className="inline-flex items-center gap-1">
<ArrowPathIcon className="mb-0.5 size-4 text-blue-400" /> Replay runs
</span>
}
description="Replays all selected runs, regardless of current status."
value={"replay"}
variant="description"
/>
<RadioGroupItem
id="action-cancel"
label={
<span className="inline-flex items-center gap-1">
<XCircleIcon className="mb-0.5 size-4 text-error" /> Cancel runs
</span>
}
description="Cancels all runs still in progress. Any finished runs wont be canceled."
value={"cancel"}
variant="description"
/>
</RadioGroup>
</InputGroup>
<InputGroup>
<Label>Preview</Label>
<BulkActionFilterSummary
selected={mode === "selected" ? selectedItems.size : data?.count}
mode={mode}
action={action}
filters={filters}
/>
</InputGroup>
</Fieldset>
</div>
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="secondary/medium"
LeadingIcon={action === "replay" ? ArrowPathIcon : XCircleIcon}
leadingIconClassName={cn(
"w-[1.3rem] h-[1.3rem]",
action === "replay" ? "text-blue-400" : "text-error"
)}
shortcut={{
modifiers: ["meta"],
key: "enter",
enabledOnInputElements: true,
}}
disabled={impactedCountElement === 0 || isDialogOpen}
>
{action === "replay" ? (
<span className="text-text-bright">Replay {impactedCountElement} runs</span>
) : (
<span className="text-text-bright">Cancel {impactedCountElement} runs</span>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{action === "replay" ? "Replay runs" : "Cancel runs"}</DialogHeader>
<div className="flex flex-col gap-3 divide-y divide-grid-dimmed pt-2">
<BulkActionFilterSummary
selected={mode === "selected" ? selectedItems.size : data?.count}
mode={mode}
action={action}
filters={filters}
/>
<Paragraph variant="small" className="pt-3">
{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 cant be canceled."}
</Paragraph>
<div className="pt-3">
<CheckboxWithLabel
name="emailNotification"
variant="simple/small"
label={`Email me when it finishes (${user.email})`}
form="bulk-action-form"
defaultChecked={false}
value="on"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="tertiary/medium"
onClick={() => setIsDialogOpen(false)}
>
Close
</Button>
<Button
type="submit"
form="bulk-action-form"
variant={action === "replay" ? "primary/medium" : "danger/medium"}
disabled={impactedCountElement === 0}
shortcut={{
modifiers: ["meta"],
key: "enter",
enabledOnInputElements: true,
}}
>
{action === "replay" ? (
<span className="text-text-bright">Replay {impactedCountElement} runs</span>
) : (
<span className="text-text-bright">Cancel {impactedCountElement} runs</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</Form>
);
}
function bulkActionModeFromString(value: string | undefined): BulkActionMode {
if (!value) return "filter";
const parsed = BulkActionMode.safeParse(value);
if (!parsed.success) return "filter";
return parsed.data;
}
function bulkActionActionFromString(value: string | undefined): BulkActionAction {
if (!value) return "replay";
const parsed = BulkActionAction.safeParse(value);
if (!parsed.success) return "replay";
return parsed.data;
}
@@ -1,95 +0,0 @@
import { parse } from "@conform-to/zod";
import { type ActionFunctionArgs } from "@remix-run/router";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { v3RunsPath } from "~/utils/pathBuilder";
import { CreateBulkActionService } from "~/v3/services/bulk/createBulkAction.server";
const FormSchema = z.object({
organizationSlug: z.string(),
projectSlug: z.string(),
environmentSlug: z.string(),
failedRedirect: z.string(),
runIds: z.array(z.string()).or(z.string()),
});
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
if (request.method.toLowerCase() !== "post") {
return redirectWithErrorMessage("/", request, "Invalid method");
}
const formData = await request.formData();
const submission = parse(formData, { schema: FormSchema });
if (!submission.value) {
logger.error("Failed to parse resources/taskruns/bulk/cancel form data", { submission });
return redirectWithErrorMessage("/", request, "Failed to parse form data");
}
try {
const project = await prisma.project.findUnique({
where: {
slug: submission.value.projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
"Project not found"
);
}
const service = new CreateBulkActionService();
const result = await service.call({
projectId: project.id,
action: "CANCEL",
runIds:
typeof submission.value.runIds === "string"
? [submission.value.runIds]
: submission.value.runIds,
});
const path = v3RunsPath(
{ slug: submission.value.organizationSlug },
{ slug: project.slug },
{ slug: submission.value.environmentSlug },
{
bulkId: result.friendlyId,
}
);
return redirectWithSuccessMessage(path, request, result.message);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to cancel runs", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
} else {
logger.error("Failed to cancel runs", { error });
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
JSON.stringify(error)
);
}
}
}
@@ -1,95 +0,0 @@
import { parse } from "@conform-to/zod";
import { type ActionFunctionArgs } from "@remix-run/router";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { v3RunsPath } from "~/utils/pathBuilder";
import { CreateBulkActionService } from "~/v3/services/bulk/createBulkAction.server";
const FormSchema = z.object({
organizationSlug: z.string(),
projectSlug: z.string(),
environmentSlug: z.string(),
failedRedirect: z.string(),
runIds: z.array(z.string()).or(z.string()),
});
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
if (request.method.toLowerCase() !== "post") {
return redirectWithErrorMessage("/", request, "Invalid method");
}
const formData = await request.formData();
const submission = parse(formData, { schema: FormSchema });
if (!submission.value) {
logger.error("Failed to parse resources/taskruns/bulk/replay form data", { submission });
return redirectWithErrorMessage("/", request, "Failed to parse form data");
}
try {
const project = await prisma.project.findUnique({
where: {
slug: submission.value.projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!project) {
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
"Project not found"
);
}
const service = new CreateBulkActionService();
const result = await service.call({
projectId: project.id,
action: "REPLAY",
runIds:
typeof submission.value.runIds === "string"
? [submission.value.runIds]
: submission.value.runIds,
});
const path = v3RunsPath(
{ slug: submission.value.organizationSlug },
{ slug: project.slug },
{ slug: submission.value.environmentSlug },
{
bulkId: result.friendlyId,
}
);
return redirectWithSuccessMessage(path, request, result.message);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to replay run", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
} else {
logger.error("Failed to replay run", { error });
return redirectWithErrorMessage(
submission.value.failedRedirect,
request,
JSON.stringify(error)
);
}
}
}
@@ -4,10 +4,7 @@ export default function Story() {
return (
<div className="flex flex-col items-start gap-y-8 p-8">
<Badge>Default</Badge>
<div className="bg-charcoal-1000 p-4">
<Badge variant="small">Small</Badge>
</div>
<Badge variant="outline">Outline</Badge>
<Badge variant="rounded">3</Badge>
<Badge variant="outline-rounded">Outline rounded</Badge>
</div>
);
@@ -309,6 +309,7 @@ export class RunEngineTriggerTaskService {
scheduleId: options.scheduleId,
scheduleInstanceId: options.scheduleInstanceId,
createdAt: options.overrideCreatedAt,
bulkActionId: body.options?.bulkActionId,
},
this.prisma
);
@@ -5,11 +5,6 @@ import { singleton } from "~/utils/singleton";
export const clickhouseClient = singleton("clickhouseClient", initializeClickhouseClient);
function initializeClickhouseClient() {
if (!env.CLICKHOUSE_URL) {
console.log("🗃️ Clickhouse service not enabled");
return;
}
const url = new URL(env.CLICKHOUSE_URL);
// Remove secure param
@@ -1,7 +1,5 @@
import { ClickHouse } from "@internal/clickhouse";
import { Logger, LogLevel } from "@trigger.dev/core/logger";
import type { PrismaClientOrTransaction, TaskRunStatus } from "@trigger.dev/database";
import { Prisma } from "@trigger.dev/database";
import { type ClickHouse } from "@internal/clickhouse";
import type { TaskRunStatus } from "@trigger.dev/database";
import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
export type DailyTaskActivity = Record<string, ({ day: string } & Record<TaskRunStatus, number>)[]>;
@@ -34,147 +32,6 @@ export interface EnvironmentMetricsRepository {
}): Promise<AverageDurations>;
}
export type PostgrestEnvironmentMetricsRepositoryOptions = {
prisma: PrismaClientOrTransaction;
schema?: string;
logger?: Logger;
logLevel?: LogLevel;
};
export class PostgrestEnvironmentMetricsRepository implements EnvironmentMetricsRepository {
private readonly logger: Logger;
private readonly schema: string;
constructor(private readonly options: PostgrestEnvironmentMetricsRepositoryOptions) {
this.logger =
options.logger ??
new Logger("PostgrestEnvironmentMetricsRepository", options.logLevel ?? "info");
this.schema = options.schema ?? "public";
}
public async getDailyTaskActivity({
environmentId,
days,
tasks,
}: {
environmentId: string;
days: number;
tasks: string[];
}): Promise<DailyTaskActivity> {
if (tasks.length === 0) {
return {};
}
const activity = await this.options.prisma.$queryRaw<
{
taskIdentifier: string;
status: TaskRunStatus;
day: Date;
count: BigInt;
}[]
>`
SELECT
tr."taskIdentifier",
tr."status",
DATE(tr."createdAt") as day,
COUNT(*)
FROM
${Prisma.sql([this.schema])}."TaskRun" as tr
WHERE
tr."taskIdentifier" IN (${Prisma.join(tasks)})
AND tr."runtimeEnvironmentId" = ${environmentId}
AND tr."createdAt" >= (current_date - interval '1 day' * ${days})
GROUP BY
tr."taskIdentifier",
tr."status",
day
ORDER BY
tr."taskIdentifier" ASC,
day ASC,
tr."status" ASC;`;
return fillInDailyTaskActivity(activity, days);
}
public async getCurrentRunningStats({
environmentId,
days,
tasks,
}: {
environmentId: string;
days: number;
tasks: string[];
}): Promise<CurrentRunningStats> {
if (tasks.length === 0) {
return {};
}
const stats = await this.options.prisma.$queryRaw<
{
taskIdentifier: string;
status: TaskRunStatus;
count: BigInt;
}[]
>`
SELECT
tr."taskIdentifier",
tr.status,
COUNT(*)
FROM
${Prisma.sql([this.schema])}."TaskRun" as tr
WHERE
tr."taskIdentifier" IN (${Prisma.join(tasks)})
AND tr."runtimeEnvironmentId" = ${environmentId}
AND tr."createdAt" >= (current_date - interval '1 day' * ${days})
AND tr."status" = ANY(ARRAY[${Prisma.join([
...QUEUED_STATUSES,
"EXECUTING",
])}]::\"TaskRunStatus\"[])
GROUP BY
tr."taskIdentifier",
tr.status
ORDER BY
tr."taskIdentifier" ASC`;
return fillInCurrentRunningStats(stats, tasks);
}
public async getAverageDurations({
environmentId,
days,
tasks,
}: {
environmentId: string;
days: number;
tasks: string[];
}): Promise<AverageDurations> {
if (tasks.length === 0) {
return {};
}
const durations = await this.options.prisma.$queryRaw<
{
taskIdentifier: string;
duration: Number;
}[]
>`
SELECT
tr."taskIdentifier",
AVG(EXTRACT(EPOCH FROM (tr."updatedAt" - COALESCE(tr."startedAt", tr."lockedAt")))) as duration
FROM
${Prisma.sql([this.schema])}."TaskRun" as tr
WHERE
tr."taskIdentifier" IN (${Prisma.join(tasks)})
AND tr."runtimeEnvironmentId" = ${environmentId}
AND tr."createdAt" >= (current_date - interval '1 day' * ${days})
AND tr."status" IN ('COMPLETED_SUCCESSFULLY', 'COMPLETED_WITH_ERRORS')
GROUP BY
tr."taskIdentifier";`;
return Object.fromEntries(durations.map((s) => [s.taskIdentifier, Number(s.duration)]));
}
}
export type ClickHouseEnvironmentMetricsRepositoryOptions = {
clickhouse: ClickHouse;
};
@@ -214,6 +214,24 @@ export class RunsReplicationService {
}
}
async backfill(runs: TaskRun[]) {
// divide into batches of 50 to get data from Postgres
const flushId = nanoid();
// Use current timestamp as LSN (high enough to be above existing data)
const now = Date.now();
const syntheticLsn = `${now.toString(16).padStart(8, "0").toUpperCase()}/00000000`;
const baseVersion = lsnToUInt64(syntheticLsn);
await this.#flushBatch(
flushId,
runs.map((run, index) => ({
_version: baseVersion + BigInt(index),
run,
event: "insert",
}))
);
}
#handleData(lsn: string, message: PgoutputMessage, parseDuration: bigint) {
this.logger.debug("Handling data", {
lsn,
@@ -515,11 +533,11 @@ export class RunsReplicationService {
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Check if this is a retryable connection error
if (this.#isRetryableConnectionError(lastError)) {
const delay = this.#calculateConnectionRetryDelay(attempt);
// Check if this is a retryable error
if (this.#isRetryableError(lastError)) {
const delay = this.#calculateRetryDelay(attempt);
this.logger.warn(`Retrying RunReplication insert due to connection error`, {
this.logger.warn(`Retrying RunReplication insert due to error`, {
operationName,
flushId,
attempt,
@@ -538,25 +556,37 @@ export class RunsReplicationService {
return [lastError, null];
}
// New method to check if an error is a retryable connection error
#isRetryableConnectionError(error: Error): boolean {
// Retry all errors except known permanent ones
#isRetryableError(error: Error): boolean {
const errorMessage = error.message.toLowerCase();
const retryableConnectionPatterns = [
"socket hang up",
"econnreset",
"connection reset",
"connection refused",
"connection timeout",
"network error",
"read econnreset",
"write econnreset",
// Permanent errors that should NOT be retried
const permanentErrorPatterns = [
"authentication failed",
"permission denied",
"invalid credentials",
"table not found",
"database not found",
"column not found",
"schema mismatch",
"invalid query",
"syntax error",
"type error",
"constraint violation",
"duplicate key",
"foreign key violation",
];
return retryableConnectionPatterns.some((pattern) => errorMessage.includes(pattern));
// If it's a known permanent error, don't retry
if (permanentErrorPatterns.some((pattern) => errorMessage.includes(pattern))) {
return false;
}
// Retry everything else
return true;
}
// New method to calculate retry delay for connection errors
#calculateConnectionRetryDelay(attempt: number): number {
#calculateRetryDelay(attempt: number): number {
// Exponential backoff: baseDelay, baseDelay*2, baseDelay*4, etc.
const delay = Math.min(
this._insertBaseDelayMs * Math.pow(2, attempt - 1),
@@ -721,6 +751,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,
};
+223 -98
View File
@@ -1,8 +1,12 @@
import { type ClickHouse } from "@internal/clickhouse";
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";
import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic";
export type RunsRepositoryOptions = {
clickhouse: ClickHouse;
@@ -12,25 +16,35 @@ export type RunsRepositoryOptions = {
tracer?: Tracer;
};
export type ListRunsOptions = {
organizationId: string;
projectId: string;
environmentId: string;
const RunStatus = z.enum(Object.values(TaskRunStatus) as [TaskRunStatus, ...TaskRunStatus[]]);
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[];
runIds?: string[];
//pagination
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<typeof RunListInputOptionsSchema>;
type FilterRunsOptions = Omit<RunListInputOptions, "period"> & {
period: number | undefined;
};
type Pagination = {
page: {
size: number;
cursor?: string;
@@ -38,89 +52,20 @@ export type ListRunsOptions = {
};
};
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();
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,
await this.#convertRunListInputOptionsToFilterRunsOptions(options)
);
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")
@@ -143,6 +88,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;
@@ -179,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,
@@ -218,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: {
@@ -226,4 +181,174 @@ export class RunsRepository {
},
};
}
async countRuns(options: RunListInputOptions) {
const queryBuilder = this.options.clickhouse.taskRuns.countQueryBuilder();
applyRunFiltersToQueryBuilder(
queryBuilder,
await this.#convertRunListInputOptionsToFilterRunsOptions(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;
}
async #convertRunListInputOptionsToFilterRunsOptions(
options: RunListInputOptions
): Promise<FilterRunsOptions> {
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;
}
}
if (options.bulkId && options.bulkId.startsWith("bulk_")) {
convertedOptions.bulkId = BulkActionId.toId(options.bulkId);
}
if (options.runIds) {
//convert to friendlyId
convertedOptions.runIds = options.runIds.map((runId) => RunId.toFriendlyId(runId));
}
// 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<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.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)}", {
runIds: options.runIds.map((runId) => RunId.toFriendlyId(runId)),
});
}
}
export function parseRunListInputOptions(data: any): RunListInputOptions {
return RunListInputOptionsSchema.parse(data);
}
@@ -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,
+31 -5
View File
@@ -167,6 +167,23 @@ 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 v3BulkActionPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
bulkAction: { friendlyId: string }
) {
return `${v3BulkActionsPath(organization, project, environment)}/${bulkAction.friendlyId}`;
}
export function v3EnvironmentVariablesPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -237,15 +254,24 @@ export function v3RunsPath(
return `${v3EnvironmentPath(organization, project, environment)}/runs${query}`;
}
export function v3RunsNextPath(
export function v3CreateBulkActionPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
filters?: TaskRunListSearchFilters
filters?: TaskRunListSearchFilters,
mode?: "selected" | "filters",
action?: "replay" | "cancel"
) {
const searchParams = objectToSearchParams(filters);
const query = searchParams ? `?${searchParams.toString()}` : "";
return `${v3EnvironmentPath(organization, project, environment)}/runs/next${query}`;
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}`;
}
export function v3RunPath(
+3 -1
View File
@@ -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());
}
+14
View File
@@ -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/BulkActionV2.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);
},
},
});
@@ -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<T>(
trace: string,
@@ -0,0 +1,423 @@
import { BulkActionId } from "@trigger.dev/core/v3/isomorphic";
import {
BulkActionNotificationType,
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";
import { parseRunListInputOptions, RunsRepository } from "~/services/runsRepository.server";
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";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import parseDuration from "parse-duration";
import { v3BulkActionPath } from "~/utils/pathBuilder";
import { formatDateTime } from "~/components/primitives/DateTime";
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);
// 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,
completionNotification:
payload.emailNotification === true
? BulkActionNotificationType.EMAIL
: BulkActionNotificationType.NONE,
},
});
// 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.findFirst({
where: { id: bulkActionId },
select: {
status: true,
friendlyId: true,
projectId: true,
environmentId: true,
project: {
select: {
organizationId: true,
slug: true,
organization: {
select: {
slug: true,
},
},
},
},
environment: {
select: {
slug: true,
},
},
type: true,
queryName: true,
params: true,
cursor: true,
completionNotification: true,
user: {
select: {
email: true,
},
},
createdAt: true,
completedAt: true,
},
});
if (!group) {
throw new Error(`Bulk action group not found: ${bulkActionId}`);
}
if (!group.environmentId || !group.environment) {
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,
projectId: group.projectId,
environmentId: group.environmentId,
...(group.params && typeof group.params === "object" ? group.params : {}),
});
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 runIds = 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
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 {
if (!result || result.alreadyFinished) {
failureCount++;
} else {
successCount++;
}
}
}
break;
}
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++;
}
}
}
break;
}
}
const isFinished = runIdsToProcess.length === 0;
logger.debug("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.at(runIdsToProcess.length - 1),
successCount: {
increment: successCount,
},
failureCount: {
increment: failureCount,
},
status: isFinished ? BulkActionStatus.COMPLETED : undefined,
completedAt: isFinished ? new Date() : undefined,
},
});
// 5. If finished, queue a notification and exit
if (isFinished) {
switch (group.completionNotification) {
case BulkActionNotificationType.NONE:
return;
case BulkActionNotificationType.EMAIL: {
if (!group.user) {
logger.error("Bulk action group has no user, skipping email notification", {
bulkActionId,
});
return;
}
await commonWorker.enqueue({
id: `bulkActionCompletionNotification-${bulkActionId}`,
job: "scheduleEmail",
payload: {
to: group.user.email,
email: "bulk-action-completed",
bulkActionId: group.friendlyId,
url: `${env.LOGIN_ORIGIN}${v3BulkActionPath(
{
slug: group.project.organization.slug,
},
{
slug: group.project.slug,
},
{
slug: group.environment.slug,
},
{
friendlyId: group.friendlyId,
}
)}`,
totalCount: successCount + failureCount,
successCount,
failureCount,
type: group.type,
createdAt: formatDateTime(group.createdAt, "UTC", [], true, true),
completedAt: formatDateTime(group.completedAt ?? new Date(), "UTC", [], true, true),
},
});
break;
}
}
return;
}
// 6. If there are more runs to process, queue the next batch
await commonWorker.enqueue({
id: `processBulkAction-${bulkActionId}`,
job: "processBulkAction",
payload: { bulkActionId },
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) {
if (payload.mode === "selected") {
return {
runIds: payload.selectedRunIds,
cursor: undefined,
direction: undefined,
};
}
const filters = await getRunFiltersFromRequest(request);
filters.cursor = undefined;
filters.direction = undefined;
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;
}
@@ -1,10 +1,10 @@
import { BulkActionType } from "@trigger.dev/database";
import { type BulkActionType } from "@trigger.dev/database";
import { bulkActionVerb } from "~/components/runs/v3/BulkAction";
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../../friendlyIdentifiers";
import { BaseService } from "../baseService.server";
import { PerformBulkActionService } from "./performBulkAction.server";
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
type BulkAction = {
projectId: string;
@@ -10,15 +10,22 @@ export type CancelTaskRunServiceOptions = {
reason?: string;
cancelAttempts?: boolean;
cancelledAt?: Date;
bulkActionId?: string;
};
type CancelTaskRunServiceResult = {
id: string;
alreadyFinished: boolean;
};
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<CancelTaskRunServiceResult | undefined> {
if (taskRun.engine === RunEngineVersion.V1) {
@@ -29,26 +36,37 @@ export class CancelTaskRunService extends BaseService {
}
private async callV1(
taskRun: TaskRun,
taskRun: CancelableTaskRun,
options?: CancelTaskRunServiceOptions
): Promise<CancelTaskRunServiceResult | undefined> {
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: TaskRun,
taskRun: CancelableTaskRun,
options?: CancelTaskRunServiceOptions
): Promise<CancelTaskRunServiceResult | undefined> {
const result = await engine.cancelRun({
runId: taskRun.id,
completedAt: options?.cancelledAt,
reason: options?.reason,
bulkActionId: options?.bulkActionId,
tx: this._prisma,
});
return {
id: result.run.id,
alreadyFinished: result.alreadyFinished,
};
}
}
@@ -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: {
@@ -28,10 +29,11 @@ export type CancelTaskRunServiceOptions = {
reason?: string;
cancelAttempts?: boolean;
cancelledAt?: Date;
bulkActionId?: string;
};
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,
@@ -45,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;
}
@@ -53,6 +68,7 @@ export class CancelTaskRunServiceV1 extends BaseService {
id: taskRun.id,
status: "CANCELED",
completedAt: opts.cancelledAt,
bulkActionId: opts.bulkActionId,
include: {
attempts: {
where: {
@@ -29,6 +29,7 @@ type BaseInput = {
error?: TaskRunError;
metadata?: FlushedRunMetadata;
env?: AuthenticatedEnvironment;
bulkActionId?: string;
};
type InputWithInclude<T extends Prisma.TaskRunInclude> = 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 } : {}),
});
@@ -15,6 +15,7 @@ type OverrideOptions = {
environmentId?: string;
payload?: unknown;
metadata?: unknown;
bulkActionId?: string;
} & RunOptionsData;
export class ReplayTaskRunService extends BaseService {
@@ -80,6 +81,7 @@ export class ReplayTaskRunService extends BaseService {
undefined,
lockToVersion:
overrideOptions.version === "latest" ? undefined : overrideOptions.version,
bulkActionId: overrideOptions?.bulkActionId,
},
},
{
@@ -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,
},
});
+2
View File
@@ -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",
+3 -3
View File
@@ -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);
@@ -1158,7 +1158,7 @@ describe("RunsRepository", () => {
},
});
await setTimeout(1000);
await setTimeout(1_000);
const runsRepository = new RunsRepository({
prisma,
@@ -1171,7 +1171,7 @@ describe("RunsRepository", () => {
projectId: project.id,
environmentId: runtimeEnvironment.id,
organizationId: organization.id,
runIds: [run1.id, run3.id],
runIds: [run1.friendlyId, run3.friendlyId],
});
expect(runs).toHaveLength(2);
@@ -10,12 +10,14 @@ import {
getCurrentRunningStats,
getAverageDurations,
getTaskUsageByOrganization,
getTaskRunsCountQueryBuilder,
} from "./taskRuns.js";
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
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?: {
@@ -144,6 +146,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),
@@ -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(),
@@ -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 $$;
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX CONCURRENTLY IF EXISTS "BulkActionItem_friendlyId_key";
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "BulkActionItem"
ALTER COLUMN "friendlyId"
DROP NOT NULL;
@@ -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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "bulkActionGroupIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "BulkActionGroup_environmentId_createdAt_idx" ON "BulkActionGroup" ("environmentId", "createdAt" DESC);
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "BulkActionNotificationType" AS ENUM ('NONE', 'EMAIL');
-- AlterTable
ALTER TABLE "BulkActionGroup"
ADD COLUMN "completionNotification" "BulkActionNotificationType" NOT NULL DEFAULT 'NONE';
@@ -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])
@@ -642,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
@@ -1977,14 +1981,45 @@ 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 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)
completionNotification BulkActionNotificationType @default(NONE)
/// The userId who did the bulk action
user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId 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
@@index([environmentId, createdAt(sort: Desc)])
}
enum BulkActionType {
@@ -1997,12 +2032,19 @@ enum BulkActionType {
enum BulkActionStatus {
PENDING
COMPLETED
ABORTED
}
enum BulkActionNotificationType {
NONE
EMAIL
}
model BulkActionItem {
id String @id @default(cuid())
friendlyId String @unique
/// @deprecated not used in new BulkActions
friendlyId String?
group BulkActionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade)
groupId String
@@ -0,0 +1,133 @@
import {
Body,
Column,
Container,
Head,
Html,
Link,
Preview,
Row,
Section,
Text,
} from "@react-email/components";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import {
anchor,
bullets,
container,
grey,
h1,
main,
paragraphLight,
sans,
} from "./components/styles";
import { z } from "zod";
import { Button } from "@react-email/components";
export const BulkActionCompletedEmailSchema = z.object({
email: z.literal("bulk-action-completed"),
bulkActionId: z.string(),
url: z.string().url(),
totalCount: z.number(),
successCount: z.number(),
failureCount: z.number(),
createdAt: z.string(),
completedAt: z.string(),
type: z.enum(["CANCEL", "REPLAY"]),
});
type BulkActionCompletedEmailProps = z.infer<typeof BulkActionCompletedEmailSchema>;
const previewDefaults: BulkActionCompletedEmailProps = {
email: "bulk-action-completed",
bulkActionId: "bulk_cmcxgmhjn0001cw7ct7g936uz",
url: "http://localhost:3000/bulk-actions/123",
totalCount: 100,
successCount: 90,
failureCount: 10,
type: "CANCEL",
createdAt: "10 Jul 2025, 15:04:04",
completedAt: "10 Jul 2025, 15:05:04",
};
export default function Email(props: BulkActionCompletedEmailProps) {
const {
bulkActionId,
url,
totalCount,
successCount,
failureCount,
type,
createdAt,
completedAt,
} = {
...previewDefaults,
...props,
};
return (
<Html>
<Head />
<Preview>Bulk action {bulkActionId} finished.</Preview>
<Body style={main}>
<Container style={container}>
<Text style={h1}>Bulk action finished</Text>
<Text style={paragraphLight}>Here's a summary of your bulk action:</Text>
<Property label="ID" value={bulkActionId} />
<Property label="Started" value={`${createdAt} (UTC)`} />
<Property label="Completed" value={`${completedAt} (UTC)`} />
<Property label={`Successfully ${pastTense(type)}`} value={`${successCount} runs`} />
<Property
label={`Failed to ${type.toLocaleLowerCase()}`}
value={`${failureCount} runs`}
/>
<Property label="Total runs" value={`${totalCount} runs`} />
<Button
style={{
...sans,
boxSizing: "border-box",
padding: "12px 24px",
borderRadius: "8px",
backgroundColor: "#615FFF",
textAlign: "center",
fontWeight: "600",
color: "#ffffff",
marginTop: "24px",
marginBottom: "32px",
}}
href={url}
>
View bulk action
</Button>
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
<Footer />
</Container>
</Body>
</Html>
);
}
function Property({ label, value }: { label: string; value: string | number }) {
return (
<Row>
<Column align="left" style={{ ...bullets, width: "33%" }}>
{label}
</Column>
<Column align="left" style={{ ...bullets, ...grey, width: "66%" }}>
{value}
</Column>
</Row>
);
}
function pastTense(type: "CANCEL" | "REPLAY") {
switch (type) {
case "CANCEL":
return "canceled";
case "REPLAY":
return "replayed";
}
}
@@ -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:
+10
View File
@@ -16,6 +16,9 @@ import MagicLinkEmail from "../emails/magic-link";
import { constructMailTransport, MailTransport, MailTransportOptions } from "./transports";
import MfaEnabledEmail, { MfaEnabledEmailSchema } from "../emails/mfa-enabled";
import MfaDisabledEmail, { MfaDisabledEmailSchema } from "../emails/mfa-disabled";
import BulkActionCompletedEmail, {
BulkActionCompletedEmailSchema,
} from "../emails/bulk-action-complete";
export { type MailTransportOptions };
@@ -32,6 +35,7 @@ export const DeliverEmailSchema = z
AlertDeploymentSuccessEmailSchema,
MfaEnabledEmailSchema,
MfaDisabledEmailSchema,
BulkActionCompletedEmailSchema,
])
.and(z.object({ to: z.string() }));
@@ -134,6 +138,12 @@ export class EmailClient {
component: <MfaDisabledEmail {...data} />,
};
}
case "bulk-action-completed": {
return {
subject: `Bulk action finished`,
component: <BulkActionCompletedEmail {...data} />,
};
}
}
}
}
@@ -389,6 +389,7 @@ export class RunEngine {
scheduleId,
scheduleInstanceId,
createdAt,
bulkActionId,
}: TriggerParams,
tx?: PrismaClientOrTransaction
): Promise<TaskRun> {
@@ -463,6 +464,7 @@ export class RunEngine {
scheduleId,
scheduleInstanceId,
createdAt,
bulkActionGroupIds: bulkActionId ? [bulkActionId] : undefined,
executionSnapshots: {
create: {
engine: "V2",
@@ -701,6 +703,7 @@ export class RunEngine {
completedAt,
reason,
finalizeRun,
bulkActionId,
tx,
}: {
runId: string;
@@ -709,8 +712,9 @@ export class RunEngine {
completedAt?: Date;
reason?: string;
finalizeRun?: boolean;
bulkActionId?: string;
tx?: PrismaClientOrTransaction;
}): Promise<ExecutionResult> {
}): Promise<ExecutionResult & { alreadyFinished: boolean }> {
return this.runAttemptSystem.cancelRun({
runId,
workerId,
@@ -718,6 +722,7 @@ export class RunEngine {
completedAt,
reason,
finalizeRun,
bulkActionId,
tx,
});
}
@@ -962,6 +962,7 @@ export class RunAttemptSystem {
completedAt,
reason,
finalizeRun,
bulkActionId,
tx,
}: {
runId: string;
@@ -970,8 +971,9 @@ export class RunAttemptSystem {
completedAt?: Date;
reason?: string;
finalizeRun?: boolean;
bulkActionId?: string;
tx?: PrismaClientOrTransaction;
}): Promise<ExecutionResult> {
}): Promise<ExecutionResult & { alreadyFinished: boolean }> {
const prisma = tx ?? this.$.prisma;
reason = reason ?? "Cancelled by user";
@@ -981,7 +983,20 @@ export class RunAttemptSystem {
//already finished, do nothing
if (latestSnapshot.executionStatus === "FINISHED") {
return executionResultFromSnapshot(latestSnapshot);
if (bulkActionId) {
await prisma.taskRun.update({
where: { id: runId },
data: {
bulkActionGroupIds: {
push: bulkActionId,
},
},
});
}
return {
alreadyFinished: true,
...executionResultFromSnapshot(latestSnapshot),
};
}
//is pending cancellation and we're not finalizing, alert the worker again
@@ -991,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
@@ -1006,6 +1024,11 @@ export class RunAttemptSystem {
status: "CANCELED",
completedAt: finalizeRun ? completedAt ?? new Date() : completedAt,
error,
bulkActionGroupIds: bulkActionId
? {
push: bulkActionId,
}
: undefined,
},
select: {
id: true,
@@ -1073,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
@@ -1142,7 +1168,10 @@ export class RunAttemptSystem {
}
}
return executionResultFromSnapshot(newSnapshot);
return {
alreadyFinished: false,
...executionResultFromSnapshot(newSnapshot),
};
});
});
}
@@ -146,6 +146,7 @@ export type TriggerParams = {
scheduleId?: string;
scheduleInstanceId?: string;
createdAt?: Date;
bulkActionId?: string;
};
export type EngineWorker = Worker<typeof workerCatalog>;
@@ -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;
+1
View File
@@ -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(),
});