Files
2026-07-13 13:32:57 +08:00

382 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { NoSymbolIcon } from "@heroicons/react/24/solid";
import { tryCatch } from "@trigger.dev/core";
import type { BulkActionType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useState } 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 { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { AbortBulkActionDialog } from "~/components/runs/v3/AbortBulkActionDialog";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { env } from "~/env.server";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.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 { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { checkPermissions } from "~/services/routeBuilders/permissions.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 = dashboardLoader(
{
params: BulkActionParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "read", resource: { type: "runs" } },
},
async ({ params, user, ability }) => {
const { organizationSlug, projectParam, envParam, bulkActionParam } = params;
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
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);
}
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
// Display flag for the Abort button — the action enforces write:runs.
const { canAbort } = checkPermissions(ability, {
canAbort: { action: "write", resource: { type: "runs" } },
});
return typedjson({ bulkAction: data, autoReloadPollIntervalMs, canAbort });
} 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 = dashboardAction(
{
params: BulkActionParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "write", resource: { type: "runs" } },
},
async ({ request, params, user }) => {
const { organizationSlug, projectParam, envParam, bulkActionParam } = params;
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
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, autoReloadPollIntervalMs, canAbort } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
useAutoRevalidate({
interval: autoReloadPollIntervalMs,
onFocus: true,
disabled: bulkAction.status !== "PENDING",
});
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
<Header2 className={cn("truncate whitespace-nowrap")}>
{bulkAction.name || bulkAction.friendlyId}
</Header2>
<LinkButton
to={v3BulkActionsPath(organization, project, environment)}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
<BulkActionStatusCombo status={bulkAction.status} />
{bulkAction.status === "PENDING" ? (
<ControlledAbortBulkActionDialog
canAbort={canAbort}
formAction={v3BulkActionPath(organization, project, environment, bulkAction)}
/>
) : null}
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="space-y-3">
<div className="px-3 pt-3">
<Meter
type={bulkAction.type}
successCount={bulkAction.successCount}
failureCount={bulkAction.failureCount}
totalCount={bulkAction.totalCount}
/>
</div>
<div className="px-3 pb-3">
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>
<CopyableText value={bulkAction.friendlyId} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Bulk action</Property.Label>
<Property.Value>
<BulkActionTypeCombo type={bulkAction.type} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>User</Property.Label>
<Property.Value>
{bulkAction.user ? (
<div className="flex items-center gap-1">
<UserAvatar
name={bulkAction.user.name}
avatarUrl={bulkAction.user.avatarUrl}
className="h-4 w-4"
/>
<Paragraph variant="extra-small">{bulkAction.user.name}</Paragraph>
</div>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={bulkAction.createdAt} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Completed</Property.Label>
<Property.Value>
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Summary</Property.Label>
<Property.Value>
<BulkActionFilterSummary
selected={bulkAction.totalCount}
mode={bulkAction.mode}
action={bulkAction.type === "REPLAY" ? "replay" : "cancel"}
filters={bulkAction.filters}
final={true}
/>
</Property.Value>
</Property.Item>
</Property.Table>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<LinkButton
to={v3CreateBulkActionPath(
organization,
project,
environment,
{
bulkId: bulkAction.friendlyId,
},
undefined,
"replay"
)}
variant="tertiary/medium"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-indigo-500"
>
Replay runs
</LinkButton>
<LinkButton
variant="tertiary/medium"
to={v3RunsPath(organization, project, environment, {
bulkId: bulkAction.friendlyId,
})}
LeadingIcon={RunsIcon}
leadingIconClassName="text-indigo-500"
>
View runs
</LinkButton>
</div>
</div>
);
}
type MeterProps = {
type: BulkActionType;
successCount: number;
failureCount: number;
totalCount: number;
};
function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Paragraph variant="small/bright">Runs</Paragraph>
<Paragraph variant="extra-small">
{formatNumber(successCount + failureCount)}/{formatNumber(totalCount)}
</Paragraph>
</div>
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-background-deep">
<motion.div
className="absolute left-0 top-0 h-full w-full bg-success"
initial={{ width: `${successPercentage}%` }}
animate={{ width: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
<motion.div
className="absolute top-0 h-full w-full bg-surface-control-hover"
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-success" />
<Paragraph variant="extra-small">
{formatNumber(successCount)} {typeText(type)} successfully
</Paragraph>
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-surface-control-hover" />
<Paragraph variant="extra-small">
{formatNumber(failureCount)} {typeText(type)} failed{" "}
{type === "CANCEL" ? " (already finished)" : ""}
</Paragraph>
</div>
</div>
</div>
);
}
function typeText(type: BulkActionType) {
switch (type) {
case "CANCEL":
return "canceled";
case "REPLAY":
return "replayed";
}
}
function ControlledAbortBulkActionDialog({
canAbort,
formAction,
}: {
canAbort: boolean;
formAction: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="danger/small"
LeadingIcon={NoSymbolIcon}
disabled={!canAbort}
tooltip={canAbort ? undefined : "You don't have permission to abort bulk actions"}
>
Abort
</Button>
</DialogTrigger>
<AbortBulkActionDialog formAction={formAction} onAbortSubmitted={() => setOpen(false)} />
</Dialog>
);
}