feat(webapp): add region override to the bulk replay action (#4022)
## Summary When replaying runs in bulk from a deployed environment, you can now choose which region the replayed runs run in. The bulk action 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. The dropdown only appears for the replay action in a deployed environment with more than one region available; cancel actions and development environments don't show it. ## Design The selected region is carried through the bulk action as a dedicated `replayRegion` param, kept separate from the run-list selection filters so it can't be confused with a region selection filter. When the action runs, each replay passes it through to the existing region override on the replay service, which already falls back to each run's original region when no override is set. "Don't override" is a sentinel value that the action normalizes away so the service only ever sees a real region or nothing. --------- Co-authored-by: Eric Allam <eric@trigger.dev>
This commit is contained in:
@@ -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.
|
||||
+78
-7
@@ -39,6 +39,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,6 +52,7 @@ import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
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 { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
@@ -82,12 +84,24 @@ export const loader = dashboardLoader(
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
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.
|
||||
@@ -95,7 +109,7 @@ export const loader = dashboardLoader(
|
||||
canCreateBulkAction: { action: "write", resource: { type: "runs" } },
|
||||
});
|
||||
|
||||
return typedjson({ ...data, canCreateBulkAction });
|
||||
return typedjson({ ...data, regions, canCreateBulkAction });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -104,6 +118,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"),
|
||||
@@ -114,6 +132,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()),
|
||||
}),
|
||||
@@ -121,6 +140,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()),
|
||||
}),
|
||||
@@ -160,6 +180,12 @@ export const action = dashboardAction(
|
||||
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(
|
||||
@@ -238,6 +264,23 @@ export function CreateBulkActionInspector({
|
||||
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"
|
||||
@@ -368,6 +411,34 @@ export function CreateBulkActionInspector({
|
||||
/>
|
||||
</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
|
||||
|
||||
@@ -46,6 +46,12 @@ export class BulkActionService extends BaseService {
|
||||
) {
|
||||
const filters = await getFilters(payload, request);
|
||||
|
||||
// Region is a replay-only override that re-routes the replayed runs. It's
|
||||
// stored alongside the run-list filters under a dedicated key so it isn't
|
||||
// mistaken for a `regions` selection filter when the params are parsed.
|
||||
const replayRegion = payload.action === "replay" ? payload.region : undefined;
|
||||
const params = replayRegion ? { ...filters, replayRegion } : filters;
|
||||
|
||||
// Count the runs that will be affected by the bulk action
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
organizationId,
|
||||
@@ -73,7 +79,7 @@ export class BulkActionService extends BaseService {
|
||||
userId,
|
||||
name: payload.title,
|
||||
type: payload.action === "cancel" ? BulkActionType.CANCEL : BulkActionType.REPLAY,
|
||||
params: filters,
|
||||
params,
|
||||
queryName: "bulk_action_v1",
|
||||
totalCount: count,
|
||||
completionNotification:
|
||||
@@ -192,6 +198,10 @@ export class BulkActionService extends BaseService {
|
||||
// 2. Parse the params
|
||||
const rawParams = group.params && typeof group.params === "object" ? group.params : {};
|
||||
const finalizeRun = "finalizeRun" in rawParams && (rawParams as any).finalizeRun === true;
|
||||
const replayRegion =
|
||||
"replayRegion" in rawParams && typeof (rawParams as any).replayRegion === "string"
|
||||
? (rawParams as any).replayRegion
|
||||
: undefined;
|
||||
const filters = parseRunListInputOptions({
|
||||
organizationId: group.project.organizationId,
|
||||
projectId: group.projectId,
|
||||
@@ -308,6 +318,7 @@ export class BulkActionService extends BaseService {
|
||||
replayService.call(run, {
|
||||
bulkActionId: bulkActionId,
|
||||
triggerSource: "dashboard",
|
||||
region: replayRegion,
|
||||
})
|
||||
);
|
||||
if (error) {
|
||||
|
||||
Reference in New Issue
Block a user