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:
+4
-1
@@ -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,
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+345
@@ -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";
|
||||
}
|
||||
}
|
||||
+297
@@ -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>
|
||||
);
|
||||
}
|
||||
-511
@@ -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>
|
||||
);
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { PageContainer } from "~/components/layout/AppLayout";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -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>
|
||||
|
||||
+179
-309
@@ -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 });
|
||||
}
|
||||
}
|
||||
+105
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+473
@@ -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 won’t 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 can’t 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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+36
@@ -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 $$;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "BulkActionItem_friendlyId_key";
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BulkActionItem"
|
||||
ALTER COLUMN "friendlyId"
|
||||
DROP NOT NULL;
|
||||
+18
@@ -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;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "bulkActionGroupIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "BulkActionGroup_environmentId_createdAt_idx" ON "BulkActionGroup" ("environmentId", "createdAt" DESC);
|
||||
+6
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user