393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
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";
|
||
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 { 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";
|
||
import {
|
||
RESIZABLE_PANEL_ANIMATION,
|
||
ResizableHandle,
|
||
ResizablePanel,
|
||
ResizablePanelGroup,
|
||
collapsibleHandleClassName,
|
||
} from "~/components/primitives/Resizable";
|
||
import {
|
||
Table,
|
||
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";
|
||
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 { rbac } from "~/services/rbac.server";
|
||
import { checkPermissions } from "~/services/routeBuilders/permissions.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);
|
||
}
|
||
|
||
// 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, {
|
||
status: 400,
|
||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||
});
|
||
}
|
||
};
|
||
|
||
export default function Page() {
|
||
const {
|
||
bulkActions,
|
||
currentPage,
|
||
totalPages,
|
||
totalCount: _totalCount,
|
||
canAbort,
|
||
} = useTypedLoaderData<typeof loader>();
|
||
const organization = useOrganization();
|
||
const project = useProject();
|
||
const environment = useEnvironment();
|
||
const { bulkActionParam } = useParams();
|
||
const isShowingInspector = bulkActionParam !== undefined;
|
||
|
||
return (
|
||
<PageContainer>
|
||
<NavBar>
|
||
<PageTitle title="Bulk actions" />
|
||
<PageAccessories>
|
||
<AdminDebugTooltip />
|
||
<LinkButton
|
||
variant={"docs/small"}
|
||
LeadingIcon={BookOpenIcon}
|
||
to={docsPath("/bulk-actions")}
|
||
>
|
||
Bulk actions docs
|
||
</LinkButton>
|
||
<LinkButton
|
||
variant="primary/small"
|
||
LeadingIcon={PlusIcon}
|
||
to={v3CreateBulkActionPath(organization, project, environment)}
|
||
shortcut={{
|
||
key: "n",
|
||
}}
|
||
>
|
||
New bulk action
|
||
</LinkButton>
|
||
</PageAccessories>
|
||
</NavBar>
|
||
<PageBody scrollable={false}>
|
||
{bulkActions.length === 0 ? (
|
||
<MainCenteredContainer className="max-w-prose">
|
||
<BulkActionsNone />
|
||
</MainCenteredContainer>
|
||
) : (
|
||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||
<ResizablePanel id="bulk-actions-main" min={"100px"}>
|
||
<div
|
||
className={cn(
|
||
"grid max-h-full min-h-full overflow-x-auto",
|
||
totalPages > 1 ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[1fr]"
|
||
)}
|
||
>
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-end gap-x-2 p-2">
|
||
<PaginationControls
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
showPageNumbers={false}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<BulkActionsTable
|
||
bulkActions={bulkActions}
|
||
totalPages={totalPages}
|
||
canAbort={canAbort}
|
||
/>
|
||
{totalPages > 1 && (
|
||
<div
|
||
className={cn(
|
||
"flex min-h-full",
|
||
totalPages > 1 && "justify-end border-t border-grid-dimmed px-2 py-3"
|
||
)}
|
||
>
|
||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ResizablePanel>
|
||
<ResizableHandle
|
||
id="bulk-actions-handle"
|
||
className={collapsibleHandleClassName(isShowingInspector)}
|
||
/>
|
||
<ResizablePanel
|
||
id="bulk-actions-inspector"
|
||
default="500px"
|
||
min="500px"
|
||
max="800px"
|
||
className="overflow-hidden"
|
||
collapsible
|
||
collapsed={!isShowingInspector}
|
||
onCollapseChange={() => {}}
|
||
collapsedSize="0px"
|
||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||
>
|
||
<div className="h-full" style={{ minWidth: 500 }}>
|
||
<Outlet />
|
||
</div>
|
||
</ResizablePanel>
|
||
</ResizablePanelGroup>
|
||
)}
|
||
</PageBody>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
function BulkActionsTable({
|
||
bulkActions,
|
||
totalPages,
|
||
canAbort,
|
||
}: {
|
||
bulkActions: BulkActionListItem[];
|
||
totalPages: number;
|
||
canAbort: boolean;
|
||
}) {
|
||
const organization = useOrganization();
|
||
const project = useProject();
|
||
const environment = useEnvironment();
|
||
const { bulkActionParam } = useParams();
|
||
|
||
return (
|
||
<Table containerClassName={cn(totalPages === 1 && "border-t-0")}>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHeaderCell>ID</TableHeaderCell>
|
||
<TableHeaderCell>Name</TableHeaderCell>
|
||
<TableHeaderCell
|
||
tooltip={
|
||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
|
||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||
<BulkActionStatusCombo status="PENDING" />
|
||
</div>
|
||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||
The bulk action is currently in progress. They can take some time if there are
|
||
lots of runs.
|
||
</Paragraph>
|
||
</div>
|
||
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
|
||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||
<BulkActionStatusCombo status="COMPLETED" />
|
||
</div>
|
||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||
The bulk action has completed successfully.
|
||
</Paragraph>
|
||
</div>
|
||
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
|
||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||
<BulkActionStatusCombo status="ABORTED" />
|
||
</div>
|
||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||
The bulk action was aborted.
|
||
</Paragraph>
|
||
</div>
|
||
</div>
|
||
}
|
||
>
|
||
Status
|
||
</TableHeaderCell>
|
||
<TableHeaderCell>Bulk action</TableHeaderCell>
|
||
<TableHeaderCell>Runs</TableHeaderCell>
|
||
<TableHeaderCell>User</TableHeaderCell>
|
||
<TableHeaderCell>Created</TableHeaderCell>
|
||
<TableHeaderCell>Completed</TableHeaderCell>
|
||
<TableHeaderCell>
|
||
<span className="sr-only">Actions</span>
|
||
</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{bulkActions.length === 0 ? (
|
||
<TableBlankRow colSpan={9}>There are no matching bulk actions</TableBlankRow>
|
||
) : (
|
||
bulkActions.map((bulkAction) => {
|
||
const path = v3BulkActionPath(organization, project, environment, bulkAction);
|
||
const isSelected = bulkActionParam === bulkAction.friendlyId;
|
||
|
||
return (
|
||
<TableRow
|
||
key={bulkAction.friendlyId}
|
||
className={isSelected ? "bg-grid-dimmed" : undefined}
|
||
>
|
||
<TableCell to={path} isTabbableCell>
|
||
<TruncatedCopyableValue value={bulkAction.friendlyId} />
|
||
</TableCell>
|
||
<TableCell to={path}>{bulkAction.name || "–"}</TableCell>
|
||
<TableCell to={path}>
|
||
<BulkActionStatusCombo status={bulkAction.status} />
|
||
</TableCell>
|
||
<TableCell to={path}>
|
||
<BulkActionTypeCombo type={bulkAction.type} />
|
||
</TableCell>
|
||
<TableCell to={path}>{bulkAction.totalCount}</TableCell>
|
||
<TableCell to={path}>
|
||
{bulkAction.user ? (
|
||
<div className="flex items-center gap-1">
|
||
<UserAvatar
|
||
name={bulkAction.user.name}
|
||
avatarUrl={bulkAction.user.avatarUrl}
|
||
className="h-4 w-4"
|
||
/>
|
||
<Paragraph variant="extra-small">{bulkAction.user.name}</Paragraph>
|
||
</div>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</TableCell>
|
||
<TableCell to={path}>
|
||
<DateTime date={bulkAction.createdAt} />
|
||
</TableCell>
|
||
<TableCell to={path}>
|
||
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : "–"}
|
||
</TableCell>
|
||
<BulkActionActionsCell bulkAction={bulkAction} path={path} canAbort={canAbort} />
|
||
</TableRow>
|
||
);
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</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>
|
||
)
|
||
}
|
||
/>
|
||
);
|
||
}
|