diff --git a/.server-changes/bulk-action-abort-from-list.md b/.server-changes/bulk-action-abort-from-list.md new file mode 100644 index 0000000000..10e55a7ee5 --- /dev/null +++ b/.server-changes/bulk-action-abort-from-list.md @@ -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. diff --git a/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx b/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx new file mode 100644 index 0000000000..e895961140 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/AbortBulkActionDialog.tsx @@ -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"; + + return ( + + Abort this bulk action? +
+ + Aborting stops this bulk action from processing any remaining runs. Runs it has already + processed won't be affected. + + onAbortSubmitted?.()}> + + + } + cancelButton={ + + + + } + /> +
+
+ ); +} 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 index 0b6ffb50ed..d949d1ad7c 100644 --- 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 @@ -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"; @@ -183,16 +185,10 @@ export default function Page() {
{bulkAction.status === "PENDING" ? ( -
- - Abort bulk action - -
+ ) : null}
@@ -358,3 +354,28 @@ function typeText(type: BulkActionType) { return "replayed"; } } + +function ControlledAbortBulkActionDialog({ + canAbort, + formAction, +}: { + canAbort: boolean; + formAction: string; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + setOpen(false)} /> + + ); +} 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 index ff209f417b..795d08bc09 100644 --- 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 @@ -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"; @@ -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"; @@ -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"; @@ -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 { @@ -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, { @@ -108,6 +126,7 @@ export default function Page() { currentPage, totalPages, totalCount: _totalCount, + canAbort, } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -164,7 +183,11 @@ export default function Page() {
)} - + {totalPages > 1 && (
User Created Completed + + Actions + {bulkActions.length === 0 ? ( - There are no matching bulk actions + There are no matching bulk actions ) : ( bulkActions.map((bulkAction) => { const path = v3BulkActionPath(organization, project, environment, bulkAction); @@ -306,6 +334,7 @@ function BulkActionsTable({ {bulkAction.completedAt ? : "–"} + ); }) @@ -314,3 +343,50 @@ function BulkActionsTable({ ); } + +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 {""}; + } + + return ( + + + + + + + ) : ( + + ) + } + /> + ); +}