feat(webapp): add region selector to test and replay task (#3082)

Adds a region selector to the Test task page and Replay run dialog, so
users can override the region from the dashboard. Disabled with a
placeholder for dev environments.

Closes #3016
This commit is contained in:
nicktrn
2026-02-18 10:39:38 +00:00
committed by GitHub
parent b793f33e59
commit 59b6eb9a3e
6 changed files with 183 additions and 18 deletions
@@ -201,6 +201,7 @@ function ReplayForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -357,6 +358,35 @@ function ReplayForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{replayData.regions.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
<Select
{...conform.select(region)}
variant="tertiary/small"
placeholder={replayData.disableVersionSelection ? "" : undefined}
dropdownIcon
items={replayData.regions}
defaultValue={replayData.region ?? undefined}
disabled={replayData.disableVersionSelection}
>
{replayData.regions.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.description ? `${r.name}${r.description}` : r.name}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{replayData.disableVersionSelection ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -54,7 +54,7 @@ import {
TestTaskPresenter,
} from "~/presenters/v3/TestTaskPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
import { TestTaskService } from "~/v3/services/testTask.server";
@@ -75,14 +75,15 @@ import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server";
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, taskParam } = v3TaskParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
@@ -90,7 +91,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
@@ -100,14 +101,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new TestTaskPresenter($replica, clickhouseClient);
try {
const result = await presenter.call({
userId,
projectId: project.id,
taskIdentifier: taskParam,
environment: environment,
});
const [result, regionsResult] = await Promise.all([
presenter.call({
userId: user.id,
projectId: project.id,
taskIdentifier: taskParam,
environment: environment,
}),
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
}),
]);
return typedjson(result);
return typedjson({ ...result, regions: regionsResult.regions });
} catch (error) {
return redirectWithErrorMessage(
v3TestPath({ slug: organizationSlug }, { slug: projectParam }, environment),
@@ -118,15 +126,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = v3TaskParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
return redirectBackWithErrorMessage(request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
return redirectBackWithErrorMessage(request, "Environment not found");
@@ -290,6 +298,7 @@ export default function Page() {
templates={result.taskRunTemplates}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
regions={result.regions}
/>
);
}
@@ -304,6 +313,7 @@ export default function Page() {
possibleTimezones={result.possibleTimezones}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
regions={result.regions}
/>
);
}
@@ -324,6 +334,7 @@ function StandardTaskForm({
templates,
disableVersionSelection,
allowArbitraryQueues,
regions,
}: {
task: StandardTaskResult["task"];
queues: Required<StandardTaskResult>["queue"][];
@@ -332,6 +343,7 @@ function StandardTaskForm({
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
regions: Region[];
}) {
const environment = useEnvironment();
const { value, replace } = useSearchParams();
@@ -373,6 +385,12 @@ function StandardTaskForm({
);
const [queueValue, setQueueValue] = useState<string | undefined>(lastRun?.queue);
const [machineValue, setMachineValue] = useState<string | undefined>(lastRun?.machinePreset);
const isDev = environment.type === "DEVELOPMENT";
const defaultRegion = regions.find((r) => r.isDefault);
const [regionValue, setRegionValue] = useState<string | undefined>(
isDev ? undefined : defaultRegion?.name
);
const [maxAttemptsValue, setMaxAttemptsValue] = useState<number | undefined>(
lastRun?.maxAttempts
);
@@ -381,6 +399,12 @@ function StandardTaskForm({
);
const [tagsValue, setTagsValue] = useState<string[]>(lastRun?.runTags ?? []);
const regionItems = regions.map((r) => ({
value: r.name,
label: r.description ? `${r.name}${r.description}` : r.name,
isDefault: r.isDefault,
}));
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
@@ -409,6 +433,7 @@ function StandardTaskForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -580,6 +605,45 @@ function StandardTaskForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
key={`region-${environment.id}`}
{...conform.select(region)}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={isDev ? undefined : (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -803,6 +867,7 @@ function ScheduledTaskForm({
templates,
disableVersionSelection,
allowArbitraryQueues,
regions,
}: {
task: ScheduledTaskResult["task"];
runs: ScheduledRun[];
@@ -812,6 +877,7 @@ function ScheduledTaskForm({
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
regions: Region[];
}) {
const environment = useEnvironment();
@@ -833,6 +899,12 @@ function ScheduledTaskForm({
);
const [queueValue, setQueueValue] = useState<string | undefined>(lastRun?.queue);
const [machineValue, setMachineValue] = useState<string | undefined>(lastRun?.machinePreset);
const isDev = environment.type === "DEVELOPMENT";
const defaultRegion = regions.find((r) => r.isDefault);
const [regionValue, setRegionValue] = useState<string | undefined>(
isDev ? undefined : defaultRegion?.name
);
const [maxAttemptsValue, setMaxAttemptsValue] = useState<number | undefined>(
lastRun?.maxAttempts
);
@@ -843,6 +915,12 @@ function ScheduledTaskForm({
const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false);
const regionItems = regions.map((r) => ({
value: r.name,
label: r.description ? `${r.name} — ${r.description}` : r.name,
isDefault: r.isDefault,
}));
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
@@ -879,6 +957,7 @@ function ScheduledTaskForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -1101,6 +1180,45 @@ function ScheduledTaskForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
key={`region-${environment.id}`}
{...conform.select(region)}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={isDev ? undefined : (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -7,7 +7,7 @@ import { $replica, prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { requireUser } from "~/services/session.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
@@ -15,6 +15,7 @@ import parseDuration from "parse-duration";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { queueTypeFromType } from "~/presenters/v3/QueueRetrievePresenter.server";
import { ReplayRunData } from "~/v3/replayTask";
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
const ParamSchema = z.object({
runParam: z.string(),
@@ -25,7 +26,8 @@ const QuerySchema = z.object({
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const user = await requireUser(request);
const userId = user.id;
const { runParam } = ParamSchema.parse(params);
const { environmentIdOverride } = QuerySchema.parse(
Object.fromEntries(new URL(request.url).searchParams)
@@ -42,6 +44,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
maxAttempts: true,
maxDurationInSeconds: true,
machinePreset: true,
workerQueue: true,
ttl: true,
idempotencyKey: true,
runTags: true,
@@ -49,6 +52,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
taskIdentifier: true,
project: {
select: {
slug: true,
environments: {
select: {
id: true,
@@ -108,13 +112,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const disableVersionSelection = environment.type === "DEVELOPMENT";
const allowArbitraryQueues = backgroundWorkers.at(0)?.engine === "V1";
const payload = await prettyPrintPacket(run.payload, run.payloadType);
const [payload, regionsResult] = await Promise.all([
prettyPrintPacket(run.payload, run.payloadType),
new RegionsPresenter().call({
userId,
projectSlug: run.project.slug,
isAdmin: user.admin || user.isImpersonating,
}),
]);
return typedjson({
concurrencyKey: run.concurrencyKey,
maxAttempts: run.maxAttempts,
maxDurationSeconds: run.maxDurationInSeconds,
machinePreset: run.machinePreset,
region: environment.type === "DEVELOPMENT" ? undefined : run.workerQueue,
regions: regionsResult.regions,
ttlSeconds: run.ttl ? parseDuration(run.ttl, "s") ?? undefined : undefined,
idempotencyKey: run.idempotencyKey,
runTags: run.runTags,
@@ -194,6 +207,7 @@ export const action: ActionFunction = async ({ request, params }) => {
maxAttempts: submission.value.maxAttempts,
maxDurationSeconds: submission.value.maxDurationSeconds,
machine: submission.value.machine,
region: submission.value.region,
delaySeconds: submission.value.delaySeconds,
idempotencyKey: submission.value.idempotencyKey,
idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds,
@@ -64,7 +64,7 @@ export class ReplayTaskRunService extends BaseService {
existingTaskRun.engine === "V1" ||
existingEnvironment.type === "DEVELOPMENT" ||
authenticatedEnvironment.type === "DEVELOPMENT";
const region = ignoreRegion ? undefined : existingTaskRun.workerQueue;
const region = ignoreRegion ? undefined : overrideOptions.region ?? existingTaskRun.workerQueue;
try {
const taskQueue = await this._prisma.taskQueue.findFirst({
@@ -28,6 +28,7 @@ export class TestTaskService extends BaseService {
maxDuration: data.maxDurationSeconds,
tags: data.tags,
machine: data.machine,
region: data.region,
lockToVersion: data.version === "latest" ? undefined : data.version,
priority: data.prioritySeconds,
},
@@ -66,6 +67,7 @@ export class TestTaskService extends BaseService {
maxDuration: data.maxDurationSeconds,
tags: data.tags,
machine: data.machine,
region: data.region,
lockToVersion: data.version === "latest" ? undefined : data.version,
priority: data.prioritySeconds,
},
+1
View File
@@ -22,6 +22,7 @@ export const RunOptionsData = z.object({
concurrencyKey: z.string().optional(),
maxAttempts: z.number().min(1).optional(),
machine: MachinePresetName.optional(),
region: z.string().optional(),
maxDurationSeconds: z
.number()
.min(0)