Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx
2026-07-13 13:32:57 +08:00

567 lines
22 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 { parseWithZod } from "@conform-to/zod";
import { ArrowPathIcon, InformationCircleIcon } from "@heroicons/react/20/solid";
import { XCircleIcon } from "@heroicons/react/24/outline";
import { Form } from "@remix-run/react";
import { tryCatch } from "@trigger.dev/core";
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 { Button } 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 { Select, SelectItem } from "~/components/primitives/Select";
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
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 { resolveOrgIdFromSlug } from "~/models/organization.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server";
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
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 { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder";
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
export const loader = dashboardLoader(
{
params: EnvironmentParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "read", resource: { type: "runs" } },
},
async ({ request, params, user, ability }) => {
const { organizationSlug, projectParam, envParam } = 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 presenter = new CreateBulkActionPresenter();
const [data, regionsResult] = await Promise.all([
presenter.call({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
request,
}),
tryCatch(
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
})
),
]);
const [regionsError, regionsData] = regionsResult;
const regions = regionsError ? [] : regionsData.regions;
// Display flag for the inspector's Cancel/Replay controls — the action
// below enforces write:runs independently.
const { canCreateBulkAction } = checkPermissions(ability, {
canCreateBulkAction: { action: "write", resource: { type: "runs" } },
});
return typedjson({ ...data, regions, canCreateBulkAction });
}
);
export const CreateBulkActionSearchParams = z.object({
mode: BulkActionMode.default("filter"),
action: BulkActionAction.default("cancel"),
});
// Sentinel for the "Override region" dropdown meaning "keep each run's original
// region". Normalized to `undefined` in the action so the service never sees it.
const REPLAY_REGION_NO_OVERRIDE_VALUE = "__no_override__";
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(),
region: 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(),
region: z.string().optional(),
failedRedirect: z.string(),
emailNotification: z.preprocess((value) => value === "on", z.boolean()),
}),
]);
export type CreateBulkActionPayload = z.infer<typeof CreateBulkActionPayload>;
export const action = dashboardAction(
{
params: EnvironmentParamSchema,
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 } = 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 formData = await request.formData();
const submission = parseWithZod(formData, { schema: CreateBulkActionPayload });
if (submission.status !== "success") {
logger.error("Invalid bulk action", {
submission,
formData: Object.fromEntries(formData),
});
return redirectWithErrorMessage("/", request, "Invalid bulk action");
}
// "Don't override" keeps each run's original region — drop it so it isn't
// stored as a real override.
if (submission.value.region === REPLAY_REGION_NO_OVERRIDE_VALUE) {
submission.value.region = undefined;
}
const service = new BulkActionService();
const [error, result] = await tryCatch(
(async () => {
const filters =
submission.value.mode === "selected"
? { runId: submission.value.selectedRunIds }
: await getRunFiltersFromRequest(request);
return service.create({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
userId: user.id,
action: submission.value.action,
title: submission.value.title,
region: submission.value.region,
emailNotification: submission.value.emailNotification,
filters,
triggerSource: "dashboard",
});
})()
);
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<string>;
hasBulkActions: boolean;
}) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof loader>();
const { value, replace, del } = useSearchParams();
const [action, setAction] = useState<BulkActionAction>(
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;
// Permissive while the fetcher is loading; the action enforces write:runs.
const canCreateBulkAction = data?.canCreateBulkAction ?? true;
const impactedCountElement =
mode === "selected" ? selectedItems.size : <EstimatedCount count={data?.count} />;
// Region is a replay-only override and only applies to deployed environments.
// The default keeps each run in its original region so a bulk action spanning
// multiple regions doesn't silently re-route runs.
const regions = data?.regions ?? [];
const showRegion =
action === "replay" && environment.type !== "DEVELOPMENT" && regions.length > 1;
const regionItems = [
{ value: REPLAY_REGION_NO_OVERRIDE_VALUE, label: "Don't override", isDefault: false },
...regions.map((r) => ({
// masterQueue is the region routing key the replay resolves against
// (WorkerGroupService matches regionOverride on masterQueue); name is display only.
value: r.masterQueue,
label: r.description ? `${r.name}${r.description}` : r.name,
isDefault: r.isDefault,
})),
];
return (
<Form
method="post"
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/bulkaction${location.search}`}
className="h-full"
id="bulk-action-form"
>
<input type="hidden" name="failedRedirect" value={`${location.pathname}${location.search}`} />
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className="whitespace-nowrap">Create a bulk action</Header2>
<Button
type="button"
variant="minimal/small"
onClick={() => del([...RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS])}
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="px-3 pt-3">
<Accordion
type="single"
collapsible
defaultValue={!hasBulkActions ? "instructions" : undefined}
>
<AccordionItem value="instructions">
<AccordionTrigger
leadingIcon={InformationCircleIcon}
leadingIconClassName="text-blue-500"
>
How to create a bulk action
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-2">
<Paragraph variant="small">
Select runs individually using the checkboxes.
</Paragraph>
<div>
<img src={selectRunsIndividually} alt="Select runs individually" />
</div>
<Paragraph variant="small">
Or select runs using the filter menus on this page.
</Paragraph>
<div>
<img src={selectRunsUsingFilters} alt="Select runs using filters" />
</div>
<Paragraph variant="small">
Then complete the form below and click Cancel runs or Replay runs.
</Paragraph>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<Fieldset className="p-3">
{Array.from(selectedItems).map((runId) => {
return <input key={runId} type="hidden" name="selectedRunIds" value={runId} />;
})}
<InputGroup>
<Label htmlFor="mode">Select</Label>
<RadioGroup
name="mode"
className="flex flex-col items-start gap-2"
defaultValue={mode}
onValueChange={(value) => {
replace({ mode: value });
}}
>
<RadioGroupItem
id="mode-filter"
label={
<span>
{data?.count === 0 ? "" : "All"} <EstimatedCount count={data?.count} /> runs
matching your filters
</span>
}
value={"filter"}
variant="button/small"
/>
<RadioGroupItem
id="mode-selected"
label={simplur`${selectedItems.size} individually selected run[|s]`}
value={"selected"}
variant="button/small"
className="grow tabular-nums"
/>
</RadioGroup>
</InputGroup>
<InputGroup>
<Label htmlFor="title">Name</Label>
<Input name="title" placeholder="A name for this bulk action" autoComplete="off" />
<Hint>Add a name to identify this bulk action (optional).</Hint>
</InputGroup>
<InputGroup>
<Label htmlFor="action">Bulk action to perform</Label>
<RadioGroup
name="action"
className="flex flex-col items-start gap-2"
value={action}
onValueChange={(value) => {
replace({ action: value });
}}
>
<RadioGroupItem
id="action-cancel"
label={
<span className="inline-flex items-center gap-1">
<XCircleIcon className="size-4 text-error" /> Cancel runs
</span>
}
description="Cancels all runs still in progress. Any finished runs wont be canceled."
value={"cancel"}
variant="description"
/>
<RadioGroupItem
id="action-replay"
label={
<span className="inline-flex items-center gap-1">
<ArrowPathIcon className="size-4 text-blue-400" /> Replay runs
</span>
}
description="Replays all selected runs, regardless of current status."
value={"replay"}
variant="description"
/>
</RadioGroup>
</InputGroup>
{showRegion && (
<InputGroup>
<Label htmlFor="region">Override region</Label>
{/* Our Select primitive uses Ariakit, which treats value={undefined}
as uncontrolled and keeps stale state when switching environments.
The key forces a remount so it reinitializes with the default value. */}
<Select
key={`bulk-region-${environment.id}`}
name="region"
variant="tertiary/medium"
dropdownIcon
items={regionItems}
defaultValue={REPLAY_REGION_NO_OVERRIDE_VALUE}
text={(value) => regionItems.find((r) => r.value === value)?.label}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
<Hint>
By default each run is replayed in its original region. Select a region to run
them all there instead.
</Hint>
</InputGroup>
)}
<InputGroup>
<Label>Preview</Label>
<BulkActionFilterSummary
selected={mode === "selected" ? selectedItems.size : data?.count}
mode={mode}
action={action}
filters={filters}
/>
</InputGroup>
</Fieldset>
</div>
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="secondary/medium"
LeadingIcon={action === "replay" ? ArrowPathIcon : XCircleIcon}
leadingIconClassName={cn(
"w-[1.3rem] h-[1.3rem]",
action === "replay" ? "text-blue-400" : "text-error"
)}
shortcut={{
modifiers: ["meta"],
key: "enter",
enabledOnInputElements: true,
}}
disabled={impactedCountElement === 0 || isDialogOpen || !canCreateBulkAction}
tooltip={
canCreateBulkAction
? undefined
: "You don't have permission to create bulk actions"
}
>
{action === "replay" ? (
<span className="text-text-bright">Replay {impactedCountElement} runs</span>
) : (
<span className="text-text-bright">Cancel {impactedCountElement} runs</span>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{action === "replay" ? "Replay runs" : "Cancel runs"}</DialogHeader>
<div className="flex flex-col gap-3 divide-y divide-grid-dimmed pt-2">
<BulkActionFilterSummary
selected={mode === "selected" ? selectedItems.size : data?.count}
mode={mode}
action={action}
filters={filters}
/>
<Paragraph variant="small" className="pt-3">
{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 cant be canceled."}
</Paragraph>
<div className="pt-3">
<CheckboxWithLabel
name="emailNotification"
variant="simple/small"
label={`Email me when it finishes (${user.email})`}
form="bulk-action-form"
defaultChecked={false}
value="on"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="tertiary/medium"
onClick={() => setIsDialogOpen(false)}
>
Close
</Button>
<Button
type="submit"
form="bulk-action-form"
variant={action === "replay" ? "primary/medium" : "danger/medium"}
disabled={impactedCountElement === 0}
shortcut={{
modifiers: ["meta"],
key: "enter",
enabledOnInputElements: true,
}}
>
{action === "replay" ? (
<span className="text-text-bright">Replay {impactedCountElement} runs</span>
) : (
<span className="text-text-bright">Cancel {impactedCountElement} runs</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</Form>
);
}
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 "cancel";
const parsed = BulkActionAction.safeParse(value);
if (!parsed.success) return "cancel";
return parsed.data;
}