Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/bulk-action-abort-from-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Abort an in-progress bulk action directly from the bulk actions list, with a confirmation step before it runs.
59 changes: 59 additions & 0 deletions apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NoSymbolIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { SpinnerWhite } from "~/components/primitives/Spinner";

type AbortBulkActionDialogProps = {
// The abort action route to POST to (the bulk action detail path).
formAction: string;
// Fired on submit so a parent controlling the Radix Dialog can close it
// without wrapping the submit button in `DialogClose` — that wrapper races
// submit (close fires first, unmounts the form, and the abort POST never
// lands). Optional so uncontrolled call sites still type-check.
onAbortSubmitted?: () => void;
};

export function AbortBulkActionDialog({
formAction,
onAbortSubmitted,
}: AbortBulkActionDialogProps) {
const navigation = useNavigation();

const isLoading = navigation.formAction === formAction && navigation.formMethod === "POST";
Comment thread
carderne marked this conversation as resolved.

return (
<DialogContent key="abort">
<DialogHeader>Abort this bulk action?</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
Aborting stops this bulk action from processing any remaining runs. Runs it has already
processed won't be affected.
</Paragraph>
<FormButtons
confirmButton={
<Form action={formAction} method="post" onSubmit={() => onAbortSubmitted?.()}>
<Button
type="submit"
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Aborting..." : "Abort bulk action"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Close</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
);
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
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 { LinkButton } from "~/components/primitives/Buttons";
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 { PermissionButton } from "~/components/primitives/PermissionButton";
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";
Expand Down Expand Up @@ -183,16 +185,10 @@ export default function Page() {
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
<BulkActionStatusCombo status={bulkAction.status} />
{bulkAction.status === "PENDING" ? (
<Form method="post">
<PermissionButton
type="submit"
variant="danger/small"
hasPermission={canAbort}
noPermissionTooltip="You don't have permission to abort bulk actions"
>
Abort bulk action
</PermissionButton>
</Form>
<ControlledAbortBulkActionDialog
canAbort={canAbort}
formAction={v3BulkActionPath(organization, project, environment, bulkAction)}
/>
) : null}
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
Expand Down Expand Up @@ -358,3 +354,28 @@ function typeText(type: BulkActionType) {
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>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid";
import { NoSymbolIcon } from "@heroicons/react/24/solid";
import { Outlet, useParams, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
Expand All @@ -7,8 +8,9 @@ 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 { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
Expand All @@ -24,11 +26,13 @@ import {
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { AbortBulkActionDialog } from "~/components/runs/v3/AbortBulkActionDialog";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { useEnvironment } from "~/hooks/useEnvironment";
Expand All @@ -40,6 +44,8 @@ import {
type BulkActionListItem,
BulkActionListPresenter,
} from "~/presenters/v3/BulkActionListPresenter.server";
import { rbac } from "~/services/rbac.server";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
Expand Down Expand Up @@ -92,7 +98,19 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Error(error.message);
}

return typedjson(data);
// Display flag for the row-menu Abort control. The abort action route
// enforces write:runs independently. Permissive in OSS.
const bulkAuth = await rbac.authenticateSession(request, {
userId,
organizationId: project.organizationId,
});
const { canAbort } = bulkAuth.ok
? checkPermissions(bulkAuth.ability, {
canAbort: { action: "write", resource: { type: "runs" } },
})
: { canAbort: true };

return typedjson({ ...data, canAbort });
} catch (error) {
console.error(error);
throw new Response(undefined, {
Expand All @@ -108,6 +126,7 @@ export default function Page() {
currentPage,
totalPages,
totalCount: _totalCount,
canAbort,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
Expand Down Expand Up @@ -164,7 +183,11 @@ export default function Page() {
</div>
)}

<BulkActionsTable bulkActions={bulkActions} totalPages={totalPages} />
<BulkActionsTable
bulkActions={bulkActions}
totalPages={totalPages}
canAbort={canAbort}
/>
{totalPages > 1 && (
<div
className={cn(
Expand Down Expand Up @@ -207,9 +230,11 @@ export default function Page() {
function BulkActionsTable({
bulkActions,
totalPages,
canAbort,
}: {
bulkActions: BulkActionListItem[];
totalPages: number;
canAbort: boolean;
}) {
const organization = useOrganization();
const project = useProject();
Expand Down Expand Up @@ -260,11 +285,14 @@ function BulkActionsTable({
<TableHeaderCell>User</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Completed</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Actions</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{bulkActions.length === 0 ? (
<TableBlankRow colSpan={8}>There are no matching bulk actions</TableBlankRow>
<TableBlankRow colSpan={9}>There are no matching bulk actions</TableBlankRow>
) : (
bulkActions.map((bulkAction) => {
const path = v3BulkActionPath(organization, project, environment, bulkAction);
Expand Down Expand Up @@ -306,6 +334,7 @@ function BulkActionsTable({
<TableCell to={path}>
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : "–"}
</TableCell>
<BulkActionActionsCell bulkAction={bulkAction} path={path} canAbort={canAbort} />
</TableRow>
);
})
Expand All @@ -314,3 +343,50 @@ function BulkActionsTable({
</Table>
);
}

function BulkActionActionsCell({
bulkAction,
path,
canAbort,
}: {
bulkAction: BulkActionListItem;
path: string;
canAbort: boolean;
}) {
// Abort is the only action, and only while the bulk action is still running.
if (bulkAction.status !== "PENDING") {
return <TableCell to={path}>{""}</TableCell>;
}

return (
<TableCellMenu
isSticky
hiddenButtons={
canAbort ? (
<Dialog>
<DialogTrigger asChild>
<Button
variant="minimal/small"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
>
<span className="text-text-bright">Abort…</span>
</Button>
</DialogTrigger>
<AbortBulkActionDialog formAction={path} />
</Dialog>
) : (
<Button
variant="minimal/small"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
disabled
tooltip="You don't have permission to abort bulk actions"
>
<span className="text-text-bright">Abort…</span>
</Button>
)
}
/>
);
}