diff --git a/.env.example b/.env.example index cf1245b434..34bbb272e7 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ APP_ORIGIN=http://localhost:3030 ELECTRIC_ORIGIN=http://localhost:3060 NODE_ENV=development +# Set this to UTC because Node.js uses the system timezone +TZ="UTC" + # Redis is used for the v3 queuing and v2 concurrency control REDIS_HOST="localhost" REDIS_PORT="6379" @@ -77,4 +80,4 @@ POSTHOG_PROJECT_KEY= # These control the server-side internal telemetry # INTERNAL_OTEL_TRACE_EXPORTER_URL= # INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1 -# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0, +# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0, \ No newline at end of file diff --git a/.github/workflows/unit-tests-webapp.yml b/.github/workflows/unit-tests-webapp.yml index e96af168c0..26599f4331 100644 --- a/.github/workflows/unit-tests-webapp.yml +++ b/.github/workflows/unit-tests-webapp.yml @@ -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() }} diff --git a/apps/webapp/app/assets/icons/ListCheckedIcon.tsx b/apps/webapp/app/assets/icons/ListCheckedIcon.tsx new file mode 100644 index 0000000000..29cb828f5d --- /dev/null +++ b/apps/webapp/app/assets/icons/ListCheckedIcon.tsx @@ -0,0 +1,48 @@ +export function ListCheckedIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/images/open-bulk-actions-panel.png b/apps/webapp/app/assets/images/open-bulk-actions-panel.png new file mode 100644 index 0000000000..a1b48f3864 Binary files /dev/null and b/apps/webapp/app/assets/images/open-bulk-actions-panel.png differ diff --git a/apps/webapp/app/assets/images/select-runs-individually.png b/apps/webapp/app/assets/images/select-runs-individually.png new file mode 100644 index 0000000000..31a5d048a8 Binary files /dev/null and b/apps/webapp/app/assets/images/select-runs-individually.png differ diff --git a/apps/webapp/app/assets/images/select-runs-using-filters.png b/apps/webapp/app/assets/images/select-runs-using-filters.png new file mode 100644 index 0000000000..78ce487d0f Binary files /dev/null and b/apps/webapp/app/assets/images/select-runs-using-filters.png differ diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index f3a4b3faa5..03ebae66ee 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -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" }: { ); } + +export function BulkActionsNone() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+
+ Create a bulk action +
+ + New bulk action + +
+
+ + + Select runs from the runs page individually. +
+ Select runs individually +
+
+
+
+ + OR + +
+
+ + + + Use the filter menu on the runs page to select just the runs you want to bulk action. + +
+ Select runs using filters +
+
+ + + Click the “Bulk actions” button in the top right of the runs page. +
+ Open the bulk action panel +
+
+
+ ); +} diff --git a/apps/webapp/app/components/BulkActionFilterSummary.tsx b/apps/webapp/app/components/BulkActionFilterSummary.tsx new file mode 100644 index 0000000000..9a815c08a4 --- /dev/null +++ b/apps/webapp/app/components/BulkActionFilterSummary.tsx @@ -0,0 +1,241 @@ +import { z } from "zod"; +import { + filterIcon, + filterTitle, + type TaskRunListSearchFilterKey, + type TaskRunListSearchFilters, +} from "./runs/v3/RunFilters"; +import { Paragraph } from "./primitives/Paragraph"; +import simplur from "simplur"; +import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v3/SharedFilters"; +import { formatNumber } from "~/utils/numberFormatter"; +import { SpinnerWhite } from "./primitives/Spinner"; +import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid"; +import assertNever from "assert-never"; +import { AppliedFilter } from "./primitives/AppliedFilter"; +import { runStatusTitle } from "./runs/v3/TaskRunStatus"; +import { type TaskRunStatus } from "@trigger.dev/database"; + +export const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]); +export type BulkActionMode = z.infer; +export const BulkActionAction = z.union([z.literal("cancel"), z.literal("replay")]); +export type BulkActionAction = z.infer; + +export function BulkActionFilterSummary({ + selected, + final = false, + mode, + action, + filters, +}: { + selected?: number; + final?: boolean; + mode: BulkActionMode; + action: BulkActionAction; + filters: TaskRunListSearchFilters; +}) { + switch (mode) { + case "selected": + return ( + + You {!final ? "have " : " "}individually selected {simplur`${selected} run[|s]`} to be{" "} + . + + ); + case "filter": { + const { label, valueLabel, rangeType } = timeFilterRenderValues({ + from: filters.from ? dateFromString(`${filters.from}`) : undefined, + to: filters.to ? dateFromString(`${filters.to}`) : undefined, + period: filters.period, + }); + + return ( +
+ + You {!final ? "have " : " "}selected{" "} + + {final ? selected : } + {" "} + runs to be using these filters: + +
+ + {Object.entries(filters).map(([key, value]) => { + if (!value && key !== "period") { + return null; + } + + const typedKey = key as TaskRunListSearchFilterKey; + + switch (typedKey) { + case "cursor": + case "direction": + case "environments": + //We need to handle time differently because we have a default + case "period": + case "from": + case "to": { + return null; + } + case "tasks": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "versions": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "statuses": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + runStatusTitle(v as TaskRunStatus)))} + removable={false} + /> + ); + } + case "tags": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "bulkId": { + return ( + + ); + } + case "rootOnly": { + return ( + + ) : ( + + ) + } + removable={false} + /> + ); + } + case "runId": { + return ( + + ); + } + case "batchId": { + return ( + + ); + } + case "scheduleId": { + return ( + + ); + } + default: { + assertNever(typedKey); + } + } + })} +
+
+ ); + } + } +} + +function Action({ action }: { action: BulkActionAction }) { + switch (action) { + case "cancel": + return ( + + + Canceled + + ); + case "replay": + return ( + + + Replayed + + ); + } +} + +export function EstimatedCount({ count }: { count?: number }) { + if (typeof count === "number") { + return <>~{formatNumber(count)}; + } + + return ; +} diff --git a/apps/webapp/app/components/ListPagination.tsx b/apps/webapp/app/components/ListPagination.tsx index 56a5c03380..6e26330677 100644 --- a/apps/webapp/app/components/ListPagination.tsx +++ b/apps/webapp/app/components/ListPagination.tsx @@ -15,57 +15,63 @@ export const DirectionSchema = z.union([z.literal("forward"), z.literal("backwar export type Direction = z.infer; export function ListPagination({ list, className }: { list: List; className?: string }) { + const bothDisabled = !list.pagination.previous && !list.pagination.next; + return ( -
+
+
); } -function NextButton({ cursor }: { cursor?: string }) { - const path = useCursorPath(cursor, "forward"); +function PreviousButton({ cursor }: { cursor?: string }) { + const path = useCursorPath(cursor, "backward"); return ( - !path && e.preventDefault()} - shortcut={{ key: "k" }} - tooltip="Next" - disabled={!path} - > - Next - +
+ !path && e.preventDefault()} + shortcut={{ key: "j" }} + tooltip="Previous" + disabled={!path} + /> +
); } -function PreviousButton({ cursor }: { cursor?: string }) { - const path = useCursorPath(cursor, "backward"); +function NextButton({ cursor }: { cursor?: string }) { + const path = useCursorPath(cursor, "forward"); return ( - !path && e.preventDefault()} - shortcut={{ key: "j" }} - tooltip="Previous" - disabled={!path} - > - Prev - +
+ !path && e.preventDefault()} + shortcut={{ key: "k" }} + tooltip="Next" + disabled={!path} + /> +
); } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index efa43603dc..de7c045ad1 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -49,6 +49,7 @@ import { v3ApiKeysPath, v3BatchesPath, v3BillingPath, + v3BulkActionsPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, @@ -86,6 +87,8 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { ListChecks } from "lucide-react"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -270,6 +273,13 @@ export function SideMenu({ + (({ className, ...props }, ref) => ( )); AccordionItem.displayName = "AccordionItem"; +type AccordionTriggerProps = React.ComponentPropsWithoutRef & { + leadingIcon?: RenderIcon; + leadingIconClassName?: string; +}; + const AccordionTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + AccordionTriggerProps +>(({ className, children, leadingIcon, leadingIconClassName, ...props }, ref) => ( svg]:rotate-180", + "flex flex-1 items-center justify-between py-2 pl-2 pr-3 text-sm text-text-bright transition group-hover:border-grid-bright hover:bg-grid-dimmed [&[data-state=open]>svg]:rotate-180", className )} {...props} > - {children} +
+ {leadingIcon && ( + + )} +
{children}
+
diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx index c67cc82a9e..b1ba1cb81e 100644 --- a/apps/webapp/app/components/primitives/AppliedFilter.tsx +++ b/apps/webapp/app/components/primitives/AppliedFilter.tsx @@ -1,21 +1,26 @@ import { XMarkIcon } from "@heroicons/react/20/solid"; -import { ReactNode } from "react"; +import { type ReactNode } from "react"; import { cn } from "~/utils/cn"; const variants = { + "secondary/small": { + box: "h-6 bg-secondary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-charcoal-600 group-hover:border-charcoal-550 text-text-bright border border-charcoal-600", + clear: "size-6 text-text-bright hover:text-text-bright transition-colors", + }, "tertiary/small": { box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-charcoal-600", clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors", }, - "minimal/small": { - box: "h-6 hover:bg-tertiary rounded pl-1.5 gap-1.5 text-xs", - clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors", + "minimal/medium": { + box: "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 )} > -
-
- {label}: +
+
+ {icon} +
+ {label}: +
-
+
{value}
diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index 04a033ba02..861ce1ff04 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -4,14 +4,12 @@ import { cn } from "~/utils/cn"; const variants = { default: "grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-charcoal-750 text-text-bright uppercase whitespace-nowrap", - small: - "grid place-items-center rounded-full px-[0.4rem] h-4 tracking-wider text-xxs bg-background-dimmed text-text-dimmed uppercase whitespace-nowrap", "extra-small": "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 whitespace-nowrap", - outline: - "grid place-items-center rounded-sm px-1.5 h-5 tracking-wider text-xxs border border-dimmed text-text-dimmed uppercase whitespace-nowrap", "outline-rounded": "grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap", + rounded: + "grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-text-bright uppercase whitespace-nowrap", }; type BadgeProps = React.HTMLAttributes & { diff --git a/apps/webapp/app/components/primitives/Pagination.tsx b/apps/webapp/app/components/primitives/Pagination.tsx index 20a1a93be2..f465083710 100644 --- a/apps/webapp/app/components/primitives/Pagination.tsx +++ b/apps/webapp/app/components/primitives/Pagination.tsx @@ -19,36 +19,75 @@ export function PaginationControls({ } return ( -
} - variant={"minimal/small"} + variant={"secondary/small"} shortcut={shortcut} - tooltipTitle={"Filter runs"} + tooltipTitle={"Filter batches"} > Filter @@ -276,10 +281,12 @@ function AppliedStatusFilter() { }> } value={appliedSummary( statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus)) )} onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" /> } @@ -396,8 +403,10 @@ function AppliedBatchIdFilter() { }> } value={batchId} onRemove={() => del(["id", "cursor", "direction"])} + variant="secondary/small" /> } diff --git a/apps/webapp/app/components/runs/v3/BulkAction.tsx b/apps/webapp/app/components/runs/v3/BulkAction.tsx index ab570b4fa1..d9f0a78441 100644 --- a/apps/webapp/app/components/runs/v3/BulkAction.tsx +++ b/apps/webapp/app/components/runs/v3/BulkAction.tsx @@ -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 ( - + ); } -export function BulkActionLabel({ type }: { type: BulkActionType }) { - return {bulkActionTitle(type)}; +export function BulkActionLabel({ type, className }: { type: BulkActionType; className?: string }) { + return {bulkActionTitle(type)}; } export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) { @@ -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 ( + + + + + ); +} + +export function BulkActionStatusIcon({ + status, + className, +}: { + status: BulkActionStatus; + className: string; +}) { + switch (status) { + case "PENDING": + return ; + case "COMPLETED": + return ; + case "ABORTED": + return ; + default: { + assertNever(status); + } + } +} + +export function BulkActionStatusLabel({ + status, + className, +}: { + status: BulkActionStatus; + className?: string; +}) { + switch (status) { + case "PENDING": + return In progress; + case "COMPLETED": + return Completed; + case "ABORTED": + return Aborted; + default: { + assertNever(status); + } + } +} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 393acb7616..498a89859a 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1,12 +1,14 @@ import * as Ariakit from "@ariakit/react"; import { + CalendarIcon, ClockIcon, FingerPrintIcon, Squares2X2Icon, TagIcon, - TrashIcon, + XMarkIcon, } from "@heroicons/react/20/solid"; import { Form, useFetcher } from "@remix-run/react"; +import { IconToggleLeft } from "@tabler/icons-react"; import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database"; import { ListChecks, ListFilterIcon } from "lucide-react"; import { matchSorter } from "match-sorter"; @@ -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; +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 ; + case "tasks": + return ; + case "tags": + return ; + case "bulkId": + return ; + case "period": + return ; + case "from": + return ; + case "to": + return ; + case "rootOnly": + return ; + case "batchId": + return ; + case "runId": + return ; + case "scheduleId": + return ; + default: + return undefined; + } +} + +export function getRunFiltersFromSearchParams( + searchParams: URLSearchParams +): TaskRunListSearchFilters { + 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") && ( )} - +
@@ -145,7 +266,7 @@ const filterTypes = [ { name: "run", title: "Run ID", icon: }, { name: "batch", title: "Batch ID", icon: }, { name: "schedule", title: "Schedule ID", icon: }, - { name: "bulk", title: "Bulk action", icon: }, + { name: "bulk", title: "Bulk action", icon: }, ] as const; type FilterType = (typeof filterTypes)[number]["name"]; @@ -162,7 +283,7 @@ function FilterMenu(props: RunFiltersProps) {
} - 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() { }> runStatusTitle(v as TaskRunStatus)))} onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" /> } @@ -421,7 +544,7 @@ function TasksDropdown({ function AppliedTaskFilter({ possibleTasks }: Pick) { const { values, del } = useSearchParams(); - if (values("tasks").length === 0) { + if (values("tasks").length === 0 || values("tasks").every((v) => v === "")) { return null; } @@ -433,6 +556,7 @@ function AppliedTaskFilter({ possibleTasks }: Pick}> { const task = possibleTasks.find((task) => task.slug === v); @@ -440,6 +564,7 @@ function AppliedTaskFilter({ possibleTasks }: Pick del(["tasks", "cursor", "direction"])} + variant="secondary/small" /> } @@ -485,7 +610,7 @@ function BulkActionsDropdown({ {trigger} { if (onClose) { onClose(); @@ -498,11 +623,20 @@ function BulkActionsDropdown({ None - {filtered.map((item, index) => ( - -
- - + {filtered.map((item) => ( + +
+ + {item.name} + +
+ + +
))} @@ -531,8 +665,10 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick}> del(["bulkId", "cursor", "direction"])} + variant="secondary/small" /> } @@ -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(); 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 ( - + {trigger} v === "")) { return null; } @@ -650,8 +789,10 @@ function AppliedTagsFilter() { }> del(["tags", "cursor", "direction"])} + variant="secondary/small" /> } @@ -678,10 +819,9 @@ function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) { return ( { replace({ rootOnly: checked ? "true" : "false", @@ -796,8 +936,10 @@ function AppliedRunIdFilter() { }> del(["runId", "cursor", "direction"])} + variant="secondary/small" /> } @@ -914,8 +1056,10 @@ function AppliedBatchIdFilter() { }> del(["batchId", "cursor", "direction"])} + variant="secondary/small" /> } @@ -1032,8 +1176,10 @@ function AppliedScheduleIdFilter() { }> del(["scheduleId", "cursor", "direction"])} + variant="secondary/small" /> } diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index 5b7478d6a1..14edfa3c7d 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -10,6 +10,7 @@ import { Label } from "~/components/primitives/Label"; import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select"; import { useSearchParams } from "~/hooks/useSearchParam"; import { Button } from "../../primitives/Buttons"; +import { filterIcon } from "./RunFilters"; export type DisplayableEnvironment = Pick & { userName?: string; @@ -100,6 +101,8 @@ if (!defaultPeriodMs) { throw new Error("Invalid default period"); } +type TimeRangeType = "period" | "range" | "from" | "to"; + export const timeFilters = ({ period, from, @@ -108,16 +111,27 @@ export const timeFilters = ({ period?: string; from?: string | number; to?: string | number; -}): { period?: string; from?: Date; to?: Date; isDefault: boolean } => { +}): { + period?: string; + from?: Date; + to?: Date; + isDefault: boolean; + rangeType: TimeRangeType; + label: string; + valueLabel: ReactNode; +} => { if (period) { - return { period, isDefault: period === defaultPeriod }; + return { period, isDefault: period === defaultPeriod, ...timeFilterRenderValues({ period }) }; } if (from && to) { + const fromDate = typeof from === "string" ? dateFromString(from) : new Date(from); + const toDate = typeof to === "string" ? dateFromString(to) : new Date(to); return { - from: typeof from === "string" ? dateFromString(from) : new Date(from), - to: typeof to === "string" ? dateFromString(to) : new Date(to), + from: fromDate, + to: toDate, isDefault: false, + ...timeFilterRenderValues({ from: fromDate, to: toDate }), }; } @@ -127,6 +141,7 @@ export const timeFilters = ({ return { from: fromDate, isDefault: false, + ...timeFilterRenderValues({ from: fromDate }), }; } @@ -136,25 +151,28 @@ export const timeFilters = ({ return { to: toDate, isDefault: false, + ...timeFilterRenderValues({ to: toDate }), }; } return { period: defaultPeriod, isDefault: true, + ...timeFilterRenderValues({ period: defaultPeriod }), }; }; -export function TimeFilter() { - const { value, del } = useSearchParams(); - - const { period, from, to } = timeFilters({ - period: value("period"), - from: value("from"), - to: value("to"), - }); +export function timeFilterRenderValues({ + from, + to, + period, +}: { + from?: Date; + to?: Date; + period?: string; +}) { + const rangeType: TimeRangeType = from && to ? "range" : from ? "from" : to ? "to" : "period"; - const rangeType = from && to ? "range" : from ? "from" : to ? "to" : "period"; let valueLabel: ReactNode; switch (rangeType) { case "period": @@ -183,13 +201,31 @@ export function TimeFilter() { ? "Created after" : "Created before"; + return { label, valueLabel, rangeType }; +} + +export function TimeFilter() { + const { value, del } = useSearchParams(); + + const { period, from, to, label, valueLabel } = timeFilters({ + period: value("period"), + from: value("from"), + to: value("to"), + }); + return ( {() => ( }> - + } period={period} @@ -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 ( @@ -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} @@ -307,11 +350,13 @@ export function TimeDropdown({
@@ -323,7 +368,11 @@ export function TimeDropdown({ enabledOnInputElements: true, }} disabled={!fromValue && !toValue} - onClick={() => apply()} + onClick={(e) => { + e.preventDefault(); + apply(); + }} + type="button" > Apply @@ -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? diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index c85963edcb..f555f26cb4 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -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({ {runs.length > 0 && ( r.id))} + checked={hasAll(runs.map((r) => r.friendlyId))} onChange={(element) => { - const ids = runs.map((r) => r.id); + const ids = runs.map((r) => r.friendlyId); const checked = element.currentTarget.checked; if (checked) { select(ids); @@ -297,9 +295,9 @@ export function TaskRunsTable({ {allowSelection && ( { - toggle(run.id); + toggle(run.friendlyId); }} ref={(r) => { checkboxes.current[index + 1] = r; @@ -309,20 +307,7 @@ export function TaskRunsTable({ )} - - - - } - asChild - disableHoverableContent - /> + @@ -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 {""}; diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index 7c64647628..95f91a34db 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -75,9 +75,7 @@ export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) { {hasFilters && (
- +
@@ -109,7 +107,7 @@ function FilterMenu() {
} - variant={"minimal/small"} + variant={"secondary/small"} shortcut={shortcut} tooltipTitle={"Filter runs"} > @@ -285,10 +283,12 @@ function AppliedStatusFilter() { }> } value={appliedSummary( statuses.map((v) => waitpointStatusTitle(v as WaitpointTokenStatus)) )} onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" /> } @@ -409,8 +409,10 @@ function AppliedTagsFilter() { }> } value={appliedSummary(values("tags"))} onRemove={() => del(["tags", "cursor", "direction"])} + variant="secondary/small" /> } @@ -527,8 +529,10 @@ function AppliedWaitpointIdFilter() { }> } value={id} onRemove={() => del(["id", "cursor", "direction"])} + variant="secondary/small" /> } @@ -594,7 +598,7 @@ function IdempotencyKeyDropdown({
setIdempotencyKey(e.target.value)} variant="small" @@ -643,8 +647,10 @@ function AppliedIdempotencyKeyFilter() { }> } value={idempotencyKey} onRemove={() => del(["idempotencyKey", "cursor", "direction"])} + variant="secondary/small" /> } diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 7934bf1a5e..b289062552 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1,8 +1,7 @@ import { z } from "zod"; +import { BoolEnv } from "./utils/boolEnv"; import { isValidDatabaseUrl } from "./utils/db"; import { isValidRegex } from "./utils/regex"; -import { BoolEnv } from "./utils/boolEnv"; -import { OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT, OTEL_LINK_COUNT_LIMIT } from "@trigger.dev/core/v3"; const EnvironmentSchema = z.object({ NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]), @@ -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; diff --git a/apps/webapp/app/hooks/useSearchParam.ts b/apps/webapp/app/hooks/useSearchParam.ts index c0f939abcc..3d0bb07e1b 100644 --- a/apps/webapp/app/hooks/useSearchParam.ts +++ b/apps/webapp/app/hooks/useSearchParam.ts @@ -7,40 +7,19 @@ type Values = Record; export function useSearchParams() { const navigate = useNavigate(); const location = useOptimisticLocation(); - const search = new URLSearchParams(location.search); - - const set = useCallback( - (values: Values) => { - for (const [param, value] of Object.entries(values)) { - if (value === undefined) { - search.delete(param); - continue; - } - - if (typeof value === "string") { - search.set(param, value); - continue; - } - - search.delete(param); - for (const v of value) { - search.append(param, v); - } - } - }, - [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; +} diff --git a/apps/webapp/app/presenters/RunFilters.server.ts b/apps/webapp/app/presenters/RunFilters.server.ts new file mode 100644 index 0000000000..91cf02e943 --- /dev/null +++ b/apps/webapp/app/presenters/RunFilters.server.ts @@ -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, + }; +} diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index 26c992d45c..46e8a3704c 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -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 { ServiceValidationError } from "~/v3/services/baseService.server"; import { ApiRetrieveRunPresenter } from "./ApiRetrieveRunPresenter.server"; -import { type RunListOptions, RunListPresenter } from "./RunListPresenter.server"; +import { NextRunListPresenter, type RunListOptions } from "./NextRunListPresenter.server"; import { BasePresenter } from "./basePresenter.server"; -import { ServiceValidationError } from "~/v3/services/baseService.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 }); diff --git a/apps/webapp/app/presenters/v3/BulkActionListPresenter.server.ts b/apps/webapp/app/presenters/v3/BulkActionListPresenter.server.ts new file mode 100644 index 0000000000..a4c6ef7b62 --- /dev/null +++ b/apps/webapp/app/presenters/v3/BulkActionListPresenter.server.ts @@ -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 +>["bulkActions"][number]; + +export class BulkActionListPresenter extends BasePresenter { + public async call({ environmentId, page }: BulkActionListOptions) { + const totalCount = await this._replica.bulkActionGroup.count({ + where: { + environmentId, + }, + }); + + const bulkActions = await this._replica.bulkActionGroup.findMany({ + select: { + friendlyId: true, + name: true, + status: true, + type: true, + createdAt: true, + completedAt: true, + totalCount: true, + user: { + select: { + name: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + where: { + environmentId, + }, + orderBy: { + createdAt: "desc", + }, + skip: ((page ?? 1) - 1) * DEFAULT_PAGE_SIZE, + take: DEFAULT_PAGE_SIZE, + }); + + return { + currentPage: page ?? 1, + totalPages: Math.ceil(totalCount / DEFAULT_PAGE_SIZE), + totalCount: totalCount, + bulkActions: bulkActions.map((bulkAction) => ({ + ...bulkAction, + user: bulkAction.user + ? { name: getUsername(bulkAction.user), avatarUrl: bulkAction.user.avatarUrl } + : undefined, + })), + }; + } +} diff --git a/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts b/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts new file mode 100644 index 0000000000..cf62cd2653 --- /dev/null +++ b/apps/webapp/app/presenters/v3/BulkActionPresenter.server.ts @@ -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, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts b/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts new file mode 100644 index 0000000000..e80c7df42b --- /dev/null +++ b/apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts @@ -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, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index 9c3d65edb6..b799c99a19 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -75,8 +75,6 @@ export class NextRunListPresenter { to, }); - const periodMs = time.period ? parseDuration(time.period) : undefined; - const hasStatusFilters = statuses && statuses.length > 0; const hasFilters = @@ -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; - } - } - - //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; + if (selectedBulkAction) { + bulkActions.push(selectedBulkAction); } } - //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 || [], diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts deleted file mode 100644 index 5b81e137cd..0000000000 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ /dev/null @@ -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>; -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, - }; - } -} diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index 59313a41c1..f1635f2337 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -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); } diff --git a/apps/webapp/app/presenters/v3/UsagePresenter.server.ts b/apps/webapp/app/presenters/v3/UsagePresenter.server.ts index d599c78481..2fac95617a 100644 --- a/apps/webapp/app/presenters/v3/UsagePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UsagePresenter.server.ts @@ -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, - }); - - 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` - 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); - }); + 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); } diff --git a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts index 08006490a9..3bc1a2b457 100644 --- a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts @@ -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 { diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index d61a68a00e..50890a4411 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -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>>; @@ -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); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx new file mode 100644 index 0000000000..a81f39dbfc --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions.$bulkActionParam/route.tsx @@ -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(); + 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 ( +
+
+ + {bulkAction.name || bulkAction.friendlyId} + + +
+
+ + {bulkAction.status === "PENDING" ? ( +
+ +
+ ) : null} +
+
+
+
+ +
+
+ + + ID + + + + + + Bulk action + + + + + + User + + {bulkAction.user ? ( +
+ + {bulkAction.user.name} +
+ ) : ( + "–" + )} +
+
+ + Created + + + + + + Completed + + {bulkAction.completedAt ? : "–"} + + + + Summary + + + + +
+
+
+
+
+ + Replay runs + + + + View runs + +
+
+ ); +} + +type MeterProps = { + type: BulkActionType; + successCount: number; + failureCount: number; + totalCount: number; +}; + +function Meter({ type, successCount, failureCount, totalCount }: MeterProps) { + const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100; + const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100; + + return ( +
+
+ Runs + + {formatNumber(successCount + failureCount)}/{formatNumber(totalCount)} + +
+
+ + +
+
+
+
+ + {formatNumber(successCount)} {typeText(type)} successfully + +
+
+
+ + {formatNumber(failureCount)} {typeText(type)} failed{" "} + {type === BulkActionType.CANCEL ? " (already finished)" : ""} + +
+
+
+ ); +} + +function typeText(type: BulkActionType) { + switch (type) { + case BulkActionType.CANCEL: + return "canceled"; + case BulkActionType.REPLAY: + return "replayed"; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx new file mode 100644 index 0000000000..f44ce5904d --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -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(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { bulkActionParam } = useParams(); + const isShowingInspector = bulkActionParam !== undefined; + + return ( + + + + + + + Bulk actions docs + + + New bulk action + + + + + {bulkActions.length === 0 ? ( + + + + ) : ( + + +
1 ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[1fr]" + )} + > + {totalPages > 1 && ( +
+ +
+ )} + + + {totalPages > 1 && ( +
1 && "justify-end border-t border-grid-dimmed px-2 py-3" + )} + > + +
+ )} +
+
+ {isShowingInspector && ( + <> + + + + + + )} +
+ )} +
+
+ ); +} + +function BulkActionsTable({ + bulkActions, + totalPages, +}: { + bulkActions: BulkActionListItem[]; + totalPages: number; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { bulkActionParam } = useParams(); + + return ( + + + + ID + Name + +
+
+ +
+ + The bulk action is currently in progress. They can take some time if there are + lots of runs. + +
+
+
+ +
+ + The bulk action has completed successfully. + +
+
+
+ +
+ + The bulk action was aborted. + +
+ + } + > + Status +
+ Bulk action + Runs + User + Created + Completed +
+
+ + {bulkActions.length === 0 ? ( + There are no matching bulk actions + ) : ( + bulkActions.map((bulkAction) => { + const path = v3BulkActionPath(organization, project, environment, bulkAction); + const isSelected = bulkActionParam === bulkAction.friendlyId; + + return ( + + + + + {bulkAction.name || "–"} + + + + + + + {bulkAction.totalCount} + + {bulkAction.user ? ( +
+ + {bulkAction.user.name} +
+ ) : ( + "–" + )} +
+ + + + + {bulkAction.completedAt ? : "–"} + +
+ ); + }) + )} +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx deleted file mode 100644 index e73b1c883e..0000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs._index/route.tsx +++ /dev/null @@ -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(); - const navigation = useNavigation(); - const isLoading = navigation.state !== "idle"; - const { isConnected } = useDevPresence(); - const project = useProject(); - const environment = useEnvironment(); - - return ( - <> - - - {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( - - )} - - - Runs docs - - - - - - {({ selectedItems }) => ( -
- -
- - Loading runs -
-
- } - > - - {(list) => ( - <> - {list.runs.length === 0 && !list.hasAnyRuns ? ( - list.possibleTasks.length === 0 ? ( - - ) : ( - - ) - ) : ( -
-
- -
- -
-
- - -
- )} - - )} -
- - -
- )} - - - - ); -} - -function BulkActionBar() { - const { selectedItems, deselectAll } = useSelectedItems(); - const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none"); - - const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT; - - return ( - - {selectedItems.size > 0 && ( - -
- - Bulk actions: - {hasSelectedMaximum ? ( - - Maximum of {selectedItems.size} runs selected - - ) : ( - {selectedItems.size} runs selected - )} -
-
- { - if (o) { - setBarState("cancel"); - } else { - setBarState("none"); - } - }} - /> - { - if (o) { - setBarState("replay"); - } else { - setBarState("none"); - } - }} - /> - -
-
- )} -
- ); -} - -function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) { - const { selectedItems } = useSelectedItems(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const failedRedirect = v3RunsNextPath(organization, project, environment); - - const formAction = `/resources/taskruns/bulk/cancel`; - - const navigation = useNavigation(); - const isLoading = navigation.formAction === formAction; - - return ( - onOpen(o)}> - - - - - Cancel {selectedItems.size} runs? - - Canceling these runs will stop them from running. Only runs that are not already finished - will be canceled, the others will remain in their existing state. - - -
- - - - - {[...selectedItems].map((runId) => ( - - ))} - -
-
-
-
- ); -} - -function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) { - const { selectedItems } = useSelectedItems(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const failedRedirect = v3RunsNextPath(organization, project, environment); - - const formAction = `/resources/taskruns/bulk/replay`; - - const navigation = useNavigation(); - const isLoading = navigation.formAction === formAction; - - return ( - onOpen(o)}> - - - - - Replay runs? - - Replaying these runs will create a new run for each with the same payload and environment - as the original. It will use the latest version of the code for each task. - - -
- - - - - {[...selectedItems].map((runId) => ( - - ))} - -
-
-
-
- ); -} - -function CreateFirstTaskInstructions() { - const organization = useOrganization(); - const project = useProject(); - return ( - - - Create a task - - } - > - - Before running a task, you must first create one. Follow the instructions on the{" "} - Tasks page to create a - task, then return here to run it. - - - - ); -} - -function RunTaskInstructions() { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - return ( - - How to run your tasks - - - - Perform a test run with a payload directly from the dashboard. - - - Test - -
-
- OR -
-
-
- - - - - Performing a real run depends on the type of trigger your task is using. - - - How to trigger a task - - -
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs/route.tsx deleted file mode 100644 index f6723ddeba..0000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.next.runs/route.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Outlet } from "@remix-run/react"; -import { PageContainer } from "~/components/layout/AppLayout"; - -export default function Page() { - return ( - - - - ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx index 9e9145be9a..a27e67f630 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx @@ -96,6 +96,7 @@ import { import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { CopyableText } from "~/components/primitives/CopyableText"; const resizableSettings = { parent: { @@ -199,7 +200,7 @@ export default function Page() { to: v3RunsPath(organization, project, environment), text: "Runs", }} - title={`Run #${run.number}`} + title={} /> {environment.type === "DEVELOPMENT" && } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 294b1a2ca8..edc9e9f34c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -1,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(); - const navigation = useNavigation(); - const isLoading = navigation.state !== "idle"; + const { data, rootOnlyDefault, filters } = useTypedLoaderData(); const { isConnected } = useDevPresence(); const project = useProject(); const environment = useEnvironment(); @@ -188,65 +136,32 @@ export default function Page() { maxSelectedItemCount={BULK_ACTION_RUN_LIMIT} > {({ selectedItems }) => ( -
- + +
+
Loading runs
- } - > - - {(list) => ( - <> - {list.runs.length === 0 && !list.hasAnyRuns ? ( - list.possibleTasks.length === 0 ? ( - - ) : ( - - ) - ) : ( -
-
- -
- -
-
- - -
- )} - - )} -
- - -
+
+ } + > + + {(list) => { + return ( + + ); + }} + + )} @@ -254,181 +169,136 @@ export default function Page() { ); } -function BulkActionBar() { - const { selectedItems, deselectAll } = useSelectedItems(); - const [barState, setBarState] = useState<"none" | "replay" | "cancel">("none"); - - const hasSelectedMaximum = selectedItems.size >= BULK_ACTION_RUN_LIMIT; - - return ( - - {selectedItems.size > 0 && ( - -
- - Bulk actions: - {hasSelectedMaximum ? ( - - Maximum of {selectedItems.size} runs selected - - ) : ( - {selectedItems.size} runs selected - )} -
-
- { - if (o) { - setBarState("cancel"); - } else { - setBarState("none"); - } - }} - /> - { - if (o) { - setBarState("replay"); - } else { - setBarState("none"); - } - }} - /> - -
-
- )} -
- ); -} - -function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) { - const { selectedItems } = useSelectedItems(); - - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const failedRedirect = v3RunsPath(organization, project, environment); - - const formAction = `/resources/taskruns/bulk/cancel`; - +function RunsList({ + list, + selectedItems, + rootOnlyDefault, + filters, +}: { + list: Awaited["data"]>; + selectedItems: Set; + rootOnlyDefault: boolean; + filters: TaskRunListSearchFilters; +}) { const navigation = useNavigation(); - const isLoading = navigation.formAction === formAction; - - return ( - onOpen(o)}> - - - - - Cancel {selectedItems.size} runs? - - Canceling these runs will stop them from running. Only runs that are not already finished - will be canceled, the others will remain in their existing state. - - -
- - - - - {[...selectedItems].map((runId) => ( - - ))} - -
-
-
-
- ); -} - -function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) { - const { selectedItems } = useSelectedItems(); - + const isLoading = navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const failedRedirect = v3RunsPath(organization, project, environment); - - const formAction = `/resources/taskruns/bulk/replay`; + const { has, replace } = useSearchParams(); - const navigation = useNavigation(); - const isLoading = navigation.formAction === formAction; + // 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 ( - onOpen(o)}> - - - - - Replay runs? - - Replaying these runs will create a new run for each with the same payload and environment - as the original. It will use the latest version of the code for each task. - - -
- - - - - {[...selectedItems].map((runId) => ( - - ))} - -
-
-
-
+ <> + {list.runs.length === 0 && !list.hasAnyRuns ? ( + list.possibleTasks.length === 0 ? ( + + ) : ( + + ) + ) : ( +
+
+ +
+ {!isShowingBulkActionInspector && ( + 0 ? "selected" : undefined + )} + LeadingIcon={ListCheckedIcon} + className={selectedItems.size > 0 ? "pr-1" : undefined} + tooltip={ +
+
+ Replay + +
+
+ Cancel + +
+
+ } + > + + Bulk action + {selectedItems.size > 0 && ( + {selectedItems.size} + )} + +
+ )} + +
+
+ + +
+ )} + +
+ + {isShowingBulkActionInspector && ( + <> + + + 0} + /> + + + )} + ); } diff --git a/apps/webapp/app/routes/account.tokens/route.tsx b/apps/webapp/app/routes/account.tokens/route.tsx index f95f53f453..7b7945c70f 100644 --- a/apps/webapp/app/routes/account.tokens/route.tsx +++ b/apps/webapp/app/routes/account.tokens/route.tsx @@ -257,7 +257,6 @@ function CreatePersonalAccessToken() { defaultValue="" icon={ShieldCheckIcon} autoComplete="off" - data-1p-ignore /> This will help you to identify your token. Tokens called "cli" are automatically diff --git a/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts b/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts new file mode 100644 index 0000000000..1584d1accc --- /dev/null +++ b/apps/webapp/app/routes/admin.api.v1.runs-replication.backfill.ts @@ -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 }); + } +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx new file mode 100644 index 0000000000..3eb4a1b208 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.$bulkActionParam.stream.tsx @@ -0,0 +1,105 @@ +import { BulkActionStatus } from "@trigger.dev/database"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { env } from "~/env.server"; +import { devPresence } from "~/presenters/v3/DevPresence.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, ProjectParamSchema } from "~/utils/pathBuilder"; +import { createSSELoader, type SendFunction } from "~/utils/sse"; + +const Params = EnvironmentParamSchema.extend({ + bulkActionParam: z.string(), +}); + +export const loader = createSSELoader({ + timeout: env.DEV_PRESENCE_SSE_TIMEOUT, + interval: env.DEV_PRESENCE_POLL_MS, + debug: false, + handler: async ({ id, controller, debug, request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam, bulkActionParam } = Params.parse(params); + + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { + id: envParam, + project: { + slug: projectParam, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }, + }); + + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const getBulkActionProgress = async (send: SendFunction) => { + try { + const bulkAction = await $replica.bulkActionGroup.findFirst({ + select: { + status: true, + successCount: true, + failureCount: true, + }, + where: { + friendlyId: bulkActionParam, + environmentId: environment.id, + }, + }); + + send({ + event: "progress", + data: JSON.stringify({ + status: bulkAction?.status, + successCount: bulkAction?.successCount, + failureCount: bulkAction?.failureCount, + }), + }); + + return bulkAction; + } catch (error) { + // Handle the case where the controller is closed + logger.debug("Failed to send bulk action progress data, stream might be closed", { error }); + return null; + } + }; + + return { + beforeStream: async () => { + logger.debug("Start dev presence listening SSE session", { + environmentId: environment.id, + }); + }, + initStream: async ({ send }) => { + const bulkAction = await getBulkActionProgress(send); + + send({ event: "time", data: new Date().toISOString() }); + + if (bulkAction?.status !== BulkActionStatus.PENDING) { + return false; + } + + return true; + }, + iterator: async ({ send, date }) => { + const bulkAction = await getBulkActionProgress(send); + + if (bulkAction?.status !== BulkActionStatus.PENDING) { + return false; + } + + return true; + }, + cleanup: async ({ send }) => { + await getBulkActionProgress(send); + }, + }; + }, +}); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx new file mode 100644 index 0000000000..04809bafa4 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx @@ -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; + +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; + hasBulkActions: boolean; +}) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const fetcher = useTypedFetcher(); + const { value, replace } = useSearchParams(); + const [action, setAction] = useState( + 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 : ; + + return ( +
+ +
+
+ Create a bulk action + +
+
+
+ + + + How to create a bulk action + + +
+ + Select runs individually using the checkboxes. + +
+ Select runs individually +
+ + Or select runs using the filter menus on this page. + +
+ Select runs using filters +
+ + Then complete the form below and click “Cancel runs” or “Replay runs”. + +
+
+
+
+
+
+ {Array.from(selectedItems).map((runId) => { + return ; + })} + + + { + replace({ mode: value }); + }} + > + + {data?.count === 0 ? "" : "All"} runs + matching your filters + + } + value={"filter"} + variant="button/small" + /> + + + + + + + Add a name to identify this bulk action (optional). + + + + { + replace({ action: value }); + }} + > + + Replay runs + + } + description="Replays all selected runs, regardless of current status." + value={"replay"} + variant="description" + /> + + Cancel runs + + } + description="Cancels all runs still in progress. Any finished runs won’t be canceled." + value={"cancel"} + variant="description" + /> + + + + + + +
+
+
+ + + + + + {action === "replay" ? "Replay runs" : "Cancel runs"} +
+ + + {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."} + +
+ +
+
+ + + + +
+
+
+
+
+ ); +} + +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; +} diff --git a/apps/webapp/app/routes/resources.taskruns.bulk.cancel.ts b/apps/webapp/app/routes/resources.taskruns.bulk.cancel.ts deleted file mode 100644 index 7a9c210934..0000000000 --- a/apps/webapp/app/routes/resources.taskruns.bulk.cancel.ts +++ /dev/null @@ -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) - ); - } - } -} diff --git a/apps/webapp/app/routes/resources.taskruns.bulk.replay.ts b/apps/webapp/app/routes/resources.taskruns.bulk.replay.ts deleted file mode 100644 index 77a3df0d6c..0000000000 --- a/apps/webapp/app/routes/resources.taskruns.bulk.replay.ts +++ /dev/null @@ -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) - ); - } - } -} diff --git a/apps/webapp/app/routes/storybook.badges/route.tsx b/apps/webapp/app/routes/storybook.badges/route.tsx index ba705d1d72..3b45b78515 100644 --- a/apps/webapp/app/routes/storybook.badges/route.tsx +++ b/apps/webapp/app/routes/storybook.badges/route.tsx @@ -4,10 +4,7 @@ export default function Story() { return (
Default -
- Small -
- Outline + 3 Outline rounded
); diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index b621d7dee6..939e3a6822 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -309,6 +309,7 @@ export class RunEngineTriggerTaskService { scheduleId: options.scheduleId, scheduleInstanceId: options.scheduleInstanceId, createdAt: options.overrideCreatedAt, + bulkActionId: body.options?.bulkActionId, }, this.prisma ); diff --git a/apps/webapp/app/services/clickhouseInstance.server.ts b/apps/webapp/app/services/clickhouseInstance.server.ts index 2d353f05b0..32fc9bc0d4 100644 --- a/apps/webapp/app/services/clickhouseInstance.server.ts +++ b/apps/webapp/app/services/clickhouseInstance.server.ts @@ -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 diff --git a/apps/webapp/app/services/environmentMetricsRepository.server.ts b/apps/webapp/app/services/environmentMetricsRepository.server.ts index 2849afbff6..e06ad05cbe 100644 --- a/apps/webapp/app/services/environmentMetricsRepository.server.ts +++ b/apps/webapp/app/services/environmentMetricsRepository.server.ts @@ -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)[]>; @@ -34,147 +32,6 @@ export interface EnvironmentMetricsRepository { }): Promise; } -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 { - 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 { - 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 { - 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; }; diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 8e32b2dbdd..3acd04e412 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -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, }; diff --git a/apps/webapp/app/services/runsRepository.server.ts b/apps/webapp/app/services/runsRepository.server.ts index 154e6967c3..5b434ea67c 100644 --- a/apps/webapp/app/services/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository.server.ts @@ -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; + +type FilterRunsOptions = Omit & { + 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 { + 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( + queryBuilder: ClickhouseQueryBuilder, + options: FilterRunsOptions +) { + queryBuilder + .where("organization_id = {organizationId: String}", { + organizationId: options.organizationId, + }) + .where("project_id = {projectId: String}", { + projectId: options.projectId, + }) + .where("environment_id = {environmentId: String}", { + environmentId: options.environmentId, + }); + + if (options.tasks && options.tasks.length > 0) { + queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks }); + } + + if (options.versions && options.versions.length > 0) { + queryBuilder.where("task_version IN {versions: Array(String)}", { + versions: options.versions, + }); + } + + if (options.statuses && options.statuses.length > 0) { + queryBuilder.where("status IN {statuses: Array(String)}", { statuses: options.statuses }); + } + + if (options.tags && options.tags.length > 0) { + queryBuilder.where("hasAny(tags, {tags: Array(String)})", { tags: options.tags }); + } + + if (options.scheduleId) { + queryBuilder.where("schedule_id = {scheduleId: String}", { scheduleId: options.scheduleId }); + } + + // Period is a number of milliseconds duration + if (options.period) { + queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", { + period: new Date(Date.now() - options.period).getTime(), + }); + } + + if (options.from) { + queryBuilder.where("created_at >= fromUnixTimestamp64Milli({from: Int64})", { + from: options.from, + }); + } + + if (options.to) { + queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to }); + } + + if (typeof options.isTest === "boolean") { + queryBuilder.where("is_test = {isTest: Boolean}", { isTest: options.isTest }); + } + + if (options.rootOnly) { + queryBuilder.where("root_run_id = ''"); + } + + if (options.batchId) { + queryBuilder.where("batch_id = {batchId: String}", { batchId: options.batchId }); + } + + if (options.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); } diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts index 83c9eab023..902d752ed0 100644 --- a/apps/webapp/app/services/worker.server.ts +++ b/apps/webapp/app/services/worker.server.ts @@ -244,6 +244,7 @@ function getWorkerQueue() { return await service.call(payload.deploymentId); }, }, + // @deprecated, new bulk actions use the new bulk actions worker "v3.performBulkAction": { priority: 0, maxAttempts: 3, @@ -253,6 +254,7 @@ function getWorkerQueue() { return await service.call(payload.bulkActionGroupId); }, }, + // @deprecated, new bulk actions use the new bulk actions worker "v3.performBulkActionItem": { priority: 0, maxAttempts: 3, diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index c4a4247ac1..93610360bc 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -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( diff --git a/apps/webapp/app/utils/searchParams.ts b/apps/webapp/app/utils/searchParams.ts index a3885ab5bc..4e3b0682b0 100644 --- a/apps/webapp/app/utils/searchParams.ts +++ b/apps/webapp/app/utils/searchParams.ts @@ -13,7 +13,9 @@ export function objectToSearchParams( Object.entries(obj).forEach(([key, value]) => { if (value === undefined) return; if (Array.isArray(value)) { - searchParams.append(key, value.join(",")); + for (const v of value) { + searchParams.append(key, v.toString()); + } } else { searchParams.append(key, value.toString()); } diff --git a/apps/webapp/app/v3/commonWorker.server.ts b/apps/webapp/app/v3/commonWorker.server.ts index 7669c1aed7..a2fae9c73c 100644 --- a/apps/webapp/app/v3/commonWorker.server.ts +++ b/apps/webapp/app/v3/commonWorker.server.ts @@ -20,6 +20,7 @@ import { ResumeBatchRunService } from "./services/resumeBatchRun.server"; import { ResumeTaskDependencyService } from "./services/resumeTaskDependency.server"; import { RetryAttemptService } from "./services/retryAttempt.server"; import { TimeoutDeploymentService } from "./services/timeoutDeployment.server"; +import { BulkActionService } from "./services/bulk/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); + }, }, }); diff --git a/apps/webapp/app/v3/services/baseService.server.ts b/apps/webapp/app/v3/services/baseService.server.ts index 7686f41b6f..58ca39aaf0 100644 --- a/apps/webapp/app/v3/services/baseService.server.ts +++ b/apps/webapp/app/v3/services/baseService.server.ts @@ -1,11 +1,14 @@ import { Span, SpanKind } from "@opentelemetry/api"; -import { PrismaClientOrTransaction, prisma } from "~/db.server"; +import { $replica, PrismaClientOrTransaction, prisma } from "~/db.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { attributesFromAuthenticatedEnv, tracer } from "../tracer.server"; import { engine, RunEngine } from "../runEngine.server"; export abstract class BaseService { - constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {} + constructor( + protected readonly _prisma: PrismaClientOrTransaction = prisma, + protected readonly _replica: PrismaClientOrTransaction = $replica + ) {} protected async traceWithEnv( trace: string, diff --git a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts new file mode 100644 index 0000000000..8ffd9cebf8 --- /dev/null +++ b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts @@ -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; +} diff --git a/apps/webapp/app/v3/services/bulk/createBulkAction.server.ts b/apps/webapp/app/v3/services/bulk/createBulkAction.server.ts index 9662974716..0b7f8860e7 100644 --- a/apps/webapp/app/v3/services/bulk/createBulkAction.server.ts +++ b/apps/webapp/app/v3/services/bulk/createBulkAction.server.ts @@ -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; diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index d664d754e8..ef2bc5ee1c 100644 --- a/apps/webapp/app/v3/services/cancelTaskRun.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRun.server.ts @@ -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 { if (taskRun.engine === RunEngineVersion.V1) { @@ -29,26 +36,37 @@ export class CancelTaskRunService extends BaseService { } private async callV1( - taskRun: TaskRun, + taskRun: CancelableTaskRun, options?: CancelTaskRunServiceOptions ): Promise { const service = new CancelTaskRunServiceV1(this._prisma); - return await service.call(taskRun, options); + const result = await service.call(taskRun, options); + + if (!result) { + return; + } + + return { + id: result.id, + alreadyFinished: false, + }; } private async callV2( - taskRun: TaskRun, + taskRun: CancelableTaskRun, options?: CancelTaskRunServiceOptions ): Promise { 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, }; } } diff --git a/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts b/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts index 5d9d621a26..fa30d7fc7b 100644 --- a/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRunV1.server.ts @@ -10,6 +10,7 @@ import { CancelAttemptService } from "./cancelAttempt.server"; import { CancelTaskAttemptDependenciesService } from "./cancelTaskAttemptDependencies.server"; import { FinalizeTaskRunService } from "./finalizeTaskRun.server"; import { getTaskEventStoreTableForRun } from "../taskEventStore.server"; +import { CancelableTaskRun } from "./cancelTaskRun.server"; type ExtendedTaskRun = Prisma.TaskRunGetPayload<{ include: { @@ -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: { diff --git a/apps/webapp/app/v3/services/finalizeTaskRun.server.ts b/apps/webapp/app/v3/services/finalizeTaskRun.server.ts index 2316fd25f4..e5e448fc90 100644 --- a/apps/webapp/app/v3/services/finalizeTaskRun.server.ts +++ b/apps/webapp/app/v3/services/finalizeTaskRun.server.ts @@ -29,6 +29,7 @@ type BaseInput = { error?: TaskRunError; metadata?: FlushedRunMetadata; env?: AuthenticatedEnvironment; + bulkActionId?: string; }; type InputWithInclude = BaseInput & { @@ -49,6 +50,7 @@ export class FinalizeTaskRunService extends BaseService { status, expiredAt, completedAt, + bulkActionId, include, attemptStatus, error, @@ -98,7 +100,17 @@ export class FinalizeTaskRunService extends BaseService { const run = await this._prisma.taskRun.update({ where: { id }, - data: { status, expiredAt, completedAt, error: taskRunError }, + data: { + status, + expiredAt, + completedAt, + error: taskRunError, + bulkActionGroupIds: bulkActionId + ? { + push: bulkActionId, + } + : undefined, + }, ...(include ? { include } : {}), }); diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index 2c45fa2b03..51501605cf 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -15,6 +15,7 @@ type OverrideOptions = { environmentId?: string; payload?: unknown; metadata?: unknown; + bulkActionId?: string; } & RunOptionsData; export class ReplayTaskRunService extends BaseService { @@ -80,6 +81,7 @@ export class ReplayTaskRunService extends BaseService { undefined, lockToVersion: overrideOptions.version === "latest" ? undefined : overrideOptions.version, + bulkActionId: overrideOptions?.bulkActionId, }, }, { diff --git a/apps/webapp/app/v3/services/triggerTaskV1.server.ts b/apps/webapp/app/v3/services/triggerTaskV1.server.ts index 5a913838ef..5fc09a524e 100644 --- a/apps/webapp/app/v3/services/triggerTaskV1.server.ts +++ b/apps/webapp/app/v3/services/triggerTaskV1.server.ts @@ -443,6 +443,9 @@ export class TriggerTaskServiceV1 extends BaseService { scheduleId: options.scheduleId, scheduleInstanceId: options.scheduleInstanceId, createdAt: options.overrideCreatedAt, + bulkActionGroupIds: body.options?.bulkActionId + ? [body.options.bulkActionId] + : undefined, }, }); diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 1e47ecf75b..7ca81fd8ee 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -167,6 +167,7 @@ const alerts = colors.red[500]; const projectSettings = colors.blue[500]; const orgSettings = colors.blue[500]; const docs = colors.blue[500]; +const bulkActions = colors.emerald[500]; /** Other variables */ const radius = "0.5rem"; @@ -242,6 +243,7 @@ module.exports = { projectSettings, orgSettings, docs, + bulkActions, }, focusStyles: { outline: "1px solid", diff --git a/apps/webapp/test/runsRepository.test.ts b/apps/webapp/test/runsRepository.test.ts index 7ee3b05b4f..4dbdef8c28 100644 --- a/apps/webapp/test/runsRepository.test.ts +++ b/apps/webapp/test/runsRepository.test.ts @@ -1063,7 +1063,7 @@ describe("RunsRepository", () => { projectId: project.id, environmentId: runtimeEnvironment.id, organizationId: organization.id, - runFriendlyIds: ["run_abc", "run_xyz"], + runIds: ["run_abc", "run_xyz"], }); expect(runs).toHaveLength(2); @@ -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); diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index 7e0894ff1a..599492eb53 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -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), diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts index 86830b5bd7..e30affaf84 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -107,6 +107,17 @@ export function getTaskRunsQueryBuilder(ch: ClickhouseReader, settings?: ClickHo }); } +export function getTaskRunsCountQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) { + return ch.queryBuilder({ + name: "getTaskRunsCount", + baseQuery: "SELECT count() as count FROM trigger_dev.task_runs_v2 FINAL", + schema: z.object({ + count: z.number().int(), + }), + settings, + }); +} + export const TaskActivityQueryResult = z.object({ task_identifier: z.string(), status: z.string(), diff --git a/internal-packages/database/prisma/migrations/20250616083614_bulk_action_v2/migration.sql b/internal-packages/database/prisma/migrations/20250616083614_bulk_action_v2/migration.sql new file mode 100644 index 0000000000..54faaebc23 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250616083614_bulk_action_v2/migration.sql @@ -0,0 +1,36 @@ +-- AlterTable +ALTER TABLE "BulkActionGroup" +ADD COLUMN IF NOT EXISTS "cursor" JSONB, +ADD COLUMN IF NOT EXISTS "environmentId" TEXT, +ADD COLUMN IF NOT EXISTS "params" JSONB, +ADD COLUMN IF NOT EXISTS "processedCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS "queryName" TEXT, +ADD COLUMN IF NOT EXISTS "reason" TEXT, +ADD COLUMN IF NOT EXISTS "totalCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS "userId" TEXT; + +-- Add foreign key constraints if they don't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'BulkActionGroup_environmentId_fkey' + ) THEN + ALTER TABLE "BulkActionGroup" + ADD CONSTRAINT "BulkActionGroup_environmentId_fkey" + FOREIGN KEY ("environmentId") + REFERENCES "RuntimeEnvironment"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'BulkActionGroup_userId_fkey' + ) THEN + ALTER TABLE "BulkActionGroup" + ADD CONSTRAINT "BulkActionGroup_userId_fkey" + FOREIGN KEY ("userId") + REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250616084546_bulk_action_item_drop_friendly_id_unique_constraint/migration.sql b/internal-packages/database/prisma/migrations/20250616084546_bulk_action_item_drop_friendly_id_unique_constraint/migration.sql new file mode 100644 index 0000000000..e476fdd9df --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250616084546_bulk_action_item_drop_friendly_id_unique_constraint/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX CONCURRENTLY IF EXISTS "BulkActionItem_friendlyId_key"; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250616084735_bulk_action_item_friendly_id_optional/migration.sql b/internal-packages/database/prisma/migrations/20250616084735_bulk_action_item_friendly_id_optional/migration.sql new file mode 100644 index 0000000000..cd879e44d4 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250616084735_bulk_action_item_friendly_id_optional/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "BulkActionItem" +ALTER COLUMN "friendlyId" +DROP NOT NULL; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250623141255_bulk_action_group_counts_completed_at/migration.sql b/internal-packages/database/prisma/migrations/20250623141255_bulk_action_group_counts_completed_at/migration.sql new file mode 100644 index 0000000000..35287c3bf9 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250623141255_bulk_action_group_counts_completed_at/migration.sql @@ -0,0 +1,18 @@ +/* +Warnings: + +- You are about to drop the column `processedCount` on the `BulkActionGroup` table. All the data in the column will be lost. +- You are about to drop the column `reason` on the `BulkActionGroup` table. All the data in the column will be lost. + + */ +-- AlterEnum +ALTER TYPE "BulkActionStatus" ADD VALUE 'ABORTED'; + +-- AlterTable +ALTER TABLE "BulkActionGroup" +DROP COLUMN "processedCount", +DROP COLUMN "reason", +ADD COLUMN "completedAt" TIMESTAMP(3), +ADD COLUMN "failureCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "name" TEXT, +ADD COLUMN "successCount" INTEGER NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250708130212_task_run_add_bulk_action_group_ids/migration.sql b/internal-packages/database/prisma/migrations/20250708130212_task_run_add_bulk_action_group_ids/migration.sql new file mode 100644 index 0000000000..f8b0c70568 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250708130212_task_run_add_bulk_action_group_ids/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TaskRun" ADD COLUMN "bulkActionGroupIds" TEXT[] DEFAULT ARRAY[]::TEXT[]; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250709131914_bulk_action_group_environment_id_created_at_index/migration.sql b/internal-packages/database/prisma/migrations/20250709131914_bulk_action_group_environment_id_created_at_index/migration.sql new file mode 100644 index 0000000000..422ee561e7 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250709131914_bulk_action_group_environment_id_created_at_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "BulkActionGroup_environmentId_createdAt_idx" ON "BulkActionGroup" ("environmentId", "createdAt" DESC); \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250710105648_bulk_action_notification_type/migration.sql b/internal-packages/database/prisma/migrations/20250710105648_bulk_action_notification_type/migration.sql new file mode 100644 index 0000000000..fad7cc23b5 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250710105648_bulk_action_notification_type/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "BulkActionNotificationType" AS ENUM ('NONE', 'EMAIL'); + +-- AlterTable +ALTER TABLE "BulkActionGroup" +ADD COLUMN "completionNotification" "BulkActionNotificationType" NOT NULL DEFAULT 'NONE'; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 2ce5de2b5c..e9c1f99af2 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -57,6 +57,7 @@ model User { personalAccessTokens PersonalAccessToken[] deployments WorkerDeployment[] backupCodes MfaBackupCode[] + bulkActions BulkActionGroup[] } model MfaBackupCode { @@ -292,6 +293,7 @@ model RuntimeEnvironment { workerInstances WorkerInstance[] executionSnapshots TaskRunExecutionSnapshot[] waitpointTags WaitpointTag[] + BulkActionGroup BulkActionGroup[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -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 diff --git a/internal-packages/emails/emails/bulk-action-complete.tsx b/internal-packages/emails/emails/bulk-action-complete.tsx new file mode 100644 index 0000000000..45f316e52d --- /dev/null +++ b/internal-packages/emails/emails/bulk-action-complete.tsx @@ -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; + +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 ( + + + Bulk action {bulkActionId} finished. + + + Bulk action finished + Here's a summary of your bulk action: + + + + + + + + + + Trigger.dev +