567 lines
22 KiB
TypeScript
567 lines
22 KiB
TypeScript
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 won’t 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 can’t 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;
|
||
}
|