diff --git a/.server-changes/bulk-replay-region-override.md b/.server-changes/bulk-replay-region-override.md
new file mode 100644
index 00000000000..000b86c0621
--- /dev/null
+++ b/.server-changes/bulk-replay-region-override.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+Add an "Override region" option to the bulk replay action so replayed runs can be routed to a chosen region, defaulting to keeping each run in its original region.
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx
index ab216bcab7e..5db51845b47 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx
@@ -40,6 +40,7 @@ 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";
@@ -51,37 +52,45 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.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 { requireUserId } from "~/services/session.server";
+import { requireUser, requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder";
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
- const userId = await requireUserId(request);
+ const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
- const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
- const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new CreateBulkActionPresenter();
- const data = await presenter.call({
- organizationId: project.organizationId,
- projectId: project.id,
- environmentId: environment.id,
- request,
- });
+ const [data, regionsResult] = await Promise.all([
+ presenter.call({
+ organizationId: project.organizationId,
+ projectId: project.id,
+ environmentId: environment.id,
+ request,
+ }),
+ new RegionsPresenter().call({
+ userId: user.id,
+ projectSlug: projectParam,
+ isAdmin: user.admin || user.isImpersonating,
+ }),
+ ]);
- return typedjson(data);
+ return typedjson({ ...data, regions: regionsResult.regions });
}
export const CreateBulkActionSearchParams = z.object({
@@ -89,6 +98,10 @@ export const CreateBulkActionSearchParams = z.object({
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"),
@@ -99,6 +112,7 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [
return [];
}, z.array(z.string())),
title: z.string().optional(),
+ region: z.string().optional(),
failedRedirect: z.string(),
emailNotification: z.preprocess((value) => value === "on", z.boolean()),
}),
@@ -106,6 +120,7 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [
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()),
}),
@@ -138,6 +153,12 @@ export async function action({ params, request }: ActionFunctionArgs) {
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(
service.create(
@@ -212,6 +233,23 @@ export function CreateBulkActionInspector({
const impactedCountElement =
mode === "selected" ? selectedItems.size :