Skip to content

Commit cbfa6fa

Browse files
committed
feat(webapp): add region override to the bulk replay action
When replaying runs in bulk from a deployed environment, you can now choose which region the replayed runs run in. The inspector shows an "Override region" dropdown that defaults to "Don't override", which keeps each run in its original region, so replaying a selection that spans multiple regions doesn't silently re-route anything. Pick a region and every matched run is replayed there instead.
1 parent 954ee5c commit cbfa6fa

3 files changed

Lines changed: 93 additions & 12 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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.

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction.tsx

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
4040
import { Label } from "~/components/primitives/Label";
4141
import { Paragraph } from "~/components/primitives/Paragraph";
4242
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
43+
import { Select, SelectItem } from "~/components/primitives/Select";
4344
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
4445
import { useEnvironment } from "~/hooks/useEnvironment";
4546
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
@@ -51,44 +52,56 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
5152
import { findProjectBySlug } from "~/models/project.server";
5253
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
5354
import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server";
55+
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
5456
import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
5557
import { logger } from "~/services/logger.server";
56-
import { requireUserId } from "~/services/session.server";
58+
import { requireUser, requireUserId } from "~/services/session.server";
5759
import { cn } from "~/utils/cn";
5860
import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder";
5961
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
6062

6163
export async function loader({ request, params }: LoaderFunctionArgs) {
62-
const userId = await requireUserId(request);
64+
const user = await requireUser(request);
6365

6466
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
6567

66-
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
68+
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
6769
if (!project) {
6870
throw new Response("Not Found", { status: 404 });
6971
}
7072

71-
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
73+
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
7274
if (!environment) {
7375
throw new Response("Not Found", { status: 404 });
7476
}
7577

7678
const presenter = new CreateBulkActionPresenter();
77-
const data = await presenter.call({
78-
organizationId: project.organizationId,
79-
projectId: project.id,
80-
environmentId: environment.id,
81-
request,
82-
});
79+
const [data, regionsResult] = await Promise.all([
80+
presenter.call({
81+
organizationId: project.organizationId,
82+
projectId: project.id,
83+
environmentId: environment.id,
84+
request,
85+
}),
86+
new RegionsPresenter().call({
87+
userId: user.id,
88+
projectSlug: projectParam,
89+
isAdmin: user.admin || user.isImpersonating,
90+
}),
91+
]);
8392

84-
return typedjson(data);
93+
return typedjson({ ...data, regions: regionsResult.regions });
8594
}
8695

8796
export const CreateBulkActionSearchParams = z.object({
8897
mode: BulkActionMode.default("filter"),
8998
action: BulkActionAction.default("cancel"),
9099
});
91100

101+
// Sentinel for the "Override region" dropdown meaning "keep each run's original
102+
// region". Normalized to `undefined` in the action so the service never sees it.
103+
const REPLAY_REGION_NO_OVERRIDE_VALUE = "__no_override__";
104+
92105
export const CreateBulkActionPayload = z.discriminatedUnion("mode", [
93106
z.object({
94107
mode: z.literal("selected"),
@@ -99,13 +112,15 @@ export const CreateBulkActionPayload = z.discriminatedUnion("mode", [
99112
return [];
100113
}, z.array(z.string())),
101114
title: z.string().optional(),
115+
region: z.string().optional(),
102116
failedRedirect: z.string(),
103117
emailNotification: z.preprocess((value) => value === "on", z.boolean()),
104118
}),
105119
z.object({
106120
mode: z.literal("filter"),
107121
action: BulkActionAction,
108122
title: z.string().optional(),
123+
region: z.string().optional(),
109124
failedRedirect: z.string(),
110125
emailNotification: z.preprocess((value) => value === "on", z.boolean()),
111126
}),
@@ -138,6 +153,12 @@ export async function action({ params, request }: ActionFunctionArgs) {
138153
return redirectWithErrorMessage("/", request, "Invalid bulk action");
139154
}
140155

156+
// "Don't override" keeps each run's original region — drop it so it isn't
157+
// stored as a real override.
158+
if (submission.value.region === REPLAY_REGION_NO_OVERRIDE_VALUE) {
159+
submission.value.region = undefined;
160+
}
161+
141162
const service = new BulkActionService();
142163
const [error, result] = await tryCatch(
143164
service.create(
@@ -212,6 +233,21 @@ export function CreateBulkActionInspector({
212233
const impactedCountElement =
213234
mode === "selected" ? selectedItems.size : <EstimatedCount count={data?.count} />;
214235

236+
// Region is a replay-only override and only applies to deployed environments.
237+
// The default keeps each run in its original region so a bulk action spanning
238+
// multiple regions doesn't silently re-route runs.
239+
const regions = data?.regions ?? [];
240+
const showRegion =
241+
action === "replay" && environment.type !== "DEVELOPMENT" && regions.length > 1;
242+
const regionItems = [
243+
{ value: REPLAY_REGION_NO_OVERRIDE_VALUE, label: "Don't override", isDefault: false },
244+
...regions.map((r) => ({
245+
value: r.name,
246+
label: r.description ? `${r.name}${r.description}` : r.name,
247+
isDefault: r.isDefault,
248+
})),
249+
];
250+
215251
return (
216252
<Form
217253
method="post"
@@ -342,6 +378,34 @@ export function CreateBulkActionInspector({
342378
/>
343379
</RadioGroup>
344380
</InputGroup>
381+
{showRegion && (
382+
<InputGroup>
383+
<Label htmlFor="region">Override region</Label>
384+
{/* Our Select primitive uses Ariakit, which treats value={undefined}
385+
as uncontrolled and keeps stale state when switching environments.
386+
The key forces a remount so it reinitializes with the default value. */}
387+
<Select
388+
key={`bulk-region-${environment.id}`}
389+
name="region"
390+
variant="tertiary/medium"
391+
dropdownIcon
392+
items={regionItems}
393+
defaultValue={REPLAY_REGION_NO_OVERRIDE_VALUE}
394+
text={(value) => regionItems.find((r) => r.value === value)?.label}
395+
>
396+
{regionItems.map((r) => (
397+
<SelectItem key={r.value} value={r.value}>
398+
{r.label}
399+
{r.isDefault ? " (default)" : ""}
400+
</SelectItem>
401+
))}
402+
</Select>
403+
<Hint>
404+
By default each run is replayed in its original region. Select a region to run
405+
them all there instead.
406+
</Hint>
407+
</InputGroup>
408+
)}
345409
<InputGroup>
346410
<Label>Preview</Label>
347411
<BulkActionFilterSummary

apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export class BulkActionService extends BaseService {
3737
) {
3838
const filters = await getFilters(payload, request);
3939

40+
// Region is a replay-only override that re-routes the replayed runs. It's
41+
// stored alongside the run-list filters under a dedicated key so it isn't
42+
// mistaken for a `regions` selection filter when the params are parsed.
43+
const replayRegion = payload.action === "replay" ? payload.region : undefined;
44+
const params = replayRegion ? { ...filters, replayRegion } : filters;
45+
4046
// Count the runs that will be affected by the bulk action
4147
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard");
4248
const runsRepository = new RunsRepository({
@@ -61,7 +67,7 @@ export class BulkActionService extends BaseService {
6167
userId,
6268
name: payload.title,
6369
type: payload.action === "cancel" ? BulkActionType.CANCEL : BulkActionType.REPLAY,
64-
params: filters,
70+
params,
6571
queryName: "bulk_action_v1",
6672
totalCount: count,
6773
completionNotification:
@@ -141,6 +147,10 @@ export class BulkActionService extends BaseService {
141147
// 2. Parse the params
142148
const rawParams = group.params && typeof group.params === "object" ? group.params : {};
143149
const finalizeRun = "finalizeRun" in rawParams && (rawParams as any).finalizeRun === true;
150+
const replayRegion =
151+
"replayRegion" in rawParams && typeof (rawParams as any).replayRegion === "string"
152+
? (rawParams as any).replayRegion
153+
: undefined;
144154
const filters = parseRunListInputOptions({
145155
organizationId: group.project.organizationId,
146156
projectId: group.projectId,
@@ -248,6 +258,7 @@ export class BulkActionService extends BaseService {
248258
replayService.call(run, {
249259
bulkActionId: bulkActionId,
250260
triggerSource: "dashboard",
261+
region: replayRegion,
251262
})
252263
);
253264
if (error) {

0 commit comments

Comments
 (0)