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; 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; hasBulkActions: boolean; }) { const [isDialogOpen, setIsDialogOpen] = useState(false); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const fetcher = useTypedFetcher(); const { value, replace, del } = useSearchParams(); const [action, setAction] = useState( 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 : ; // 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 (
Create a bulk action
How to create a bulk action
Select runs individually using the checkboxes.
Select runs individually
Or select runs using the filter menus on this page.
Select runs using filters
Then complete the form below and click “Cancel runs” or “Replay runs”.
{Array.from(selectedItems).map((runId) => { return ; })} { replace({ mode: value }); }} > {data?.count === 0 ? "" : "All"} runs matching your filters } value={"filter"} variant="button/small" /> Add a name to identify this bulk action (optional). { replace({ action: value }); }} > Cancel runs } description="Cancels all runs still in progress. Any finished runs won’t be canceled." value={"cancel"} variant="description" /> Replay runs } description="Replays all selected runs, regardless of current status." value={"replay"} variant="description" /> {showRegion && ( {/* 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. */} By default each run is replayed in its original region. Select a region to run them all there instead. )}
{action === "replay" ? "Replay runs" : "Cancel runs"}
{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."}
); } 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; }