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:
@@ -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
|
||||
|
||||
+132
-14
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user