Files
2026-07-13 13:32:57 +08:00

796 lines
30 KiB
TypeScript

import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData, useParams, type MetaFunction } from "@remix-run/react";
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import type { CreatePrivateLinkConnectionBody } from "@trigger.dev/platform";
import { createPrivateLink, getPrivateLinkRegions } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import {
docsPath,
OrganizationParamsSchema,
organizationPath,
v3PrivateConnectionsPath,
} from "~/utils/pathBuilder";
import {
ArrowTopRightOnSquareIcon,
BookOpenIcon,
CommandLineIcon,
DocumentTextIcon,
PencilSquareIcon,
SparklesIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
export const meta: MetaFunction = () => {
return [{ title: `Add Private Connection | Trigger.dev` }];
};
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
if (!canAccess) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
const [_error, regions] = await tryCatch(getPrivateLinkRegions(organization.id));
const awsAccountIds = env.PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS?.split(",").filter(Boolean) ?? [];
return typedjson({
availableRegions: regions?.availableRegions ?? ["us-east-1", "eu-central-1"],
activeRegions: regions?.activeRegions ?? [],
awsAccountIds,
});
}
const schema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name must be 100 characters or less"),
endpointServiceName: z
.string()
.min(1, "VPC Endpoint Service name is required")
.regex(
/^com\.amazonaws\.vpce\..+\.vpce-svc-.+$/,
"Must be a valid VPC Endpoint Service name (com.amazonaws.vpce.<region>.vpce-svc-*)"
),
targetRegion: z.string().min(1, "Region is required"),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
return redirectWithErrorMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
"Organization not found"
);
}
// Fetch available regions dynamically (same call the loader makes)
const [, fetchedRegions] = await tryCatch(getPrivateLinkRegions(organization.id));
const availableRegions = fetchedRegions?.availableRegions ?? ["us-east-1", "eu-central-1"];
const { targetRegion: selectedRegion, ...rest } = submission.value;
if (!availableRegions.includes(selectedRegion)) {
return redirectWithErrorMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
`Invalid region: ${selectedRegion}`
);
}
const [error] = await tryCatch(
createPrivateLink(organization.id, {
...rest,
targetRegion: selectedRegion as CreatePrivateLinkConnectionBody["targetRegion"],
})
);
if (error) {
return redirectWithErrorMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
error.message
);
}
const message = "Connection created! Provisioning will begin shortly.";
return redirectWithSuccessMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
message
);
};
type SetupMethod = "manual" | "ai" | "terraform" | "docs";
type PortEntry = { port: string; protocol: "TCP" | "UDP" };
const AWS_REGIONS = [
{ value: "us-east-1", label: "US East (N. Virginia)" },
{ value: "us-east-2", label: "US East (Ohio)" },
{ value: "us-west-1", label: "US West (N. California)" },
{ value: "us-west-2", label: "US West (Oregon)" },
{ value: "af-south-1", label: "Africa (Cape Town)" },
{ value: "ap-east-1", label: "Asia Pacific (Hong Kong)" },
{ value: "ap-south-1", label: "Asia Pacific (Mumbai)" },
{ value: "ap-south-2", label: "Asia Pacific (Hyderabad)" },
{ value: "ap-southeast-1", label: "Asia Pacific (Singapore)" },
{ value: "ap-southeast-2", label: "Asia Pacific (Sydney)" },
{ value: "ap-southeast-3", label: "Asia Pacific (Jakarta)" },
{ value: "ap-southeast-4", label: "Asia Pacific (Melbourne)" },
{ value: "ap-northeast-1", label: "Asia Pacific (Tokyo)" },
{ value: "ap-northeast-2", label: "Asia Pacific (Seoul)" },
{ value: "ap-northeast-3", label: "Asia Pacific (Osaka)" },
{ value: "ca-central-1", label: "Canada (Central)" },
{ value: "ca-west-1", label: "Canada West (Calgary)" },
{ value: "eu-central-1", label: "Europe (Frankfurt)" },
{ value: "eu-central-2", label: "Europe (Zurich)" },
{ value: "eu-west-1", label: "Europe (Ireland)" },
{ value: "eu-west-2", label: "Europe (London)" },
{ value: "eu-west-3", label: "Europe (Paris)" },
{ value: "eu-south-1", label: "Europe (Milan)" },
{ value: "eu-south-2", label: "Europe (Spain)" },
{ value: "eu-north-1", label: "Europe (Stockholm)" },
{ value: "il-central-1", label: "Israel (Tel Aviv)" },
{ value: "me-south-1", label: "Middle East (Bahrain)" },
{ value: "me-central-1", label: "Middle East (UAE)" },
{ value: "sa-east-1", label: "South America (São Paulo)" },
];
function TerraformWizard({ awsAccountIds }: { awsAccountIds: string[] }) {
const [hostname, setHostname] = useState("");
const [ports, setPorts] = useState<PortEntry[]>([{ port: "5432", protocol: "TCP" }]);
const [region, setRegion] = useState("us-east-1");
const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]);
const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index));
const updatePort = (index: number, field: keyof PortEntry, value: string) =>
setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
const validPorts = ports.filter((p) => p.port !== "");
const terraformScript = `# Trigger.dev Private Networking - Terraform Configuration
# Creates an NLB and VPC Endpoint Service for your resource
variable "vpc_id" {
description = "Your VPC ID"
type = string
}
variable "subnet_ids" {
description = "Private subnet IDs in your VPC"
type = list(string)
}
variable "target_ip" {
description = "IP address of the target resource"
type = string${hostname ? `\n default = "${hostname}"` : ""}
}
# Network Load Balancer
resource "aws_lb" "trigger_privatelink" {
name = "trigger-privatelink"
internal = true
load_balancer_type = "network"
subnets = var.subnet_ids
}
${validPorts
.map(
(p, i) => `
resource "aws_lb_target_group" "port_${p.port}" {
name = "trigger-pl-${p.port}"
port = ${p.port}
protocol = "${p.protocol}"
vpc_id = var.vpc_id
target_type = "ip"
health_check {
protocol = "TCP"
port = ${p.port}
}
}
resource "aws_lb_target_group_attachment" "port_${p.port}" {
target_group_arn = aws_lb_target_group.port_${p.port}.arn
target_id = var.target_ip
port = ${p.port}
}
resource "aws_lb_listener" "port_${p.port}" {
load_balancer_arn = aws_lb.trigger_privatelink.arn
port = ${p.port}
protocol = "${p.protocol}"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.port_${p.port}.arn
}
}`
)
.join("\n")}
# VPC Endpoint Service
resource "aws_vpc_endpoint_service" "trigger_privatelink" {
acceptance_required = false
network_load_balancer_arns = [aws_lb.trigger_privatelink.arn]
# Trigger.dev runs in us-east-1 and eu-central-1. Listing both makes this
# service consumable from either region so any of your tasks can connect.
supported_regions = ["us-east-1", "eu-central-1"]
allowed_principals = [
${awsAccountIds.map((id) => ` "arn:aws:iam::${id}:root",`).join("\n")}
]
}
output "endpoint_service_name" {
description = "Paste this into the Trigger.dev dashboard"
value = aws_vpc_endpoint_service.trigger_privatelink.service_name
}
`;
return (
<div className="space-y-4">
<InputGroup>
<Label>Resource hostname or IP</Label>
<Input
value={hostname}
onChange={(e) => setHostname(e.target.value)}
placeholder="my-database.abc123.us-east-1.rds.amazonaws.com"
fullWidth
/>
</InputGroup>
<div>
<Label>Ports</Label>
<div className="mt-1 space-y-2">
{ports.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
type="number"
min={1}
max={65535}
value={entry.port}
onChange={(e) => updatePort(index, "port", e.target.value)}
placeholder="Port"
className="w-24"
/>
<select
value={entry.protocol}
onChange={(e) => updatePort(index, "protocol", e.target.value)}
className="rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
>
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
</select>
{ports.length > 1 && (
<button
type="button"
onClick={() => removePort(index)}
className="text-text-dimmed transition hover:text-rose-400"
title="Remove port"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={addPort}
className="text-xs text-indigo-400 transition hover:text-indigo-300"
>
+ Add port
</button>
</div>
</div>
<InputGroup>
<Label>Resource AWS Region</Label>
<Select
variant="tertiary/medium"
value={region}
setValue={(v) => setRegion(v)}
items={AWS_REGIONS}
filter={(item, search) => {
const s = search.toLowerCase();
return item.value.toLowerCase().includes(s) || item.label.toLowerCase().includes(s);
}}
text={(value) => {
const r = AWS_REGIONS.find((r) => r.value === value);
return r ? `${r.value}${r.label}` : value;
}}
placeholder="Select a region"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="text-text-bright">{item.value}</span>
<span className="ml-2 text-text-dimmed">{item.label}</span>
</SelectItem>
))
}
</Select>
</InputGroup>
<div className="rounded-md border border-grid-bright bg-background-deep">
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
<span className="text-xs font-medium text-text-dimmed">main.tf</span>
<button
type="button"
onClick={() => navigator.clipboard.writeText(terraformScript)}
className="text-xs text-text-dimmed transition hover:text-text-bright"
>
Copy
</button>
</div>
<pre className="max-h-64 overflow-auto p-3 text-xs text-text-dimmed">
<code>{terraformScript}</code>
</pre>
</div>
</div>
);
}
function AIPromptWizard({ awsAccountIds }: { awsAccountIds: string[] }) {
const [hostname, setHostname] = useState("");
const [ports, setPorts] = useState<PortEntry[]>([{ port: "5432", protocol: "TCP" }]);
const [region, setRegion] = useState("us-east-1");
const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]);
const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index));
const updatePort = (index: number, field: keyof PortEntry, value: string) =>
setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
const validPorts = ports.filter((p) => p.port !== "");
const regionLabel = AWS_REGIONS.find((r) => r.value === region)?.label ?? region;
const _portsDescription =
validPorts.length > 0
? validPorts.map((p) => `${p.port} (${p.protocol})`).join(", ")
: "5432 (TCP)";
const prompt = `I need to set up AWS PrivateLink so that Trigger.dev can connect to my resource. Please create the following in my AWS account in the ${region} (${regionLabel}) region:
1. A Network Load Balancer (NLB):
- Name: trigger-privatelink
- Internal: yes
- Type: network
- Place it in my private subnets
2. For each of the following ports, create a target group, target group attachment, and listener:
${validPorts.length > 0 ? validPorts.map((p) => ` - Port ${p.port} (${p.protocol})`).join("\n") : " - Port 5432 (TCP)"}
Each target group should:
- Target type: ip
- Target IP: ${hostname || "<my-resource-ip>"}
- Have a TCP health check on the same port
3. A VPC Endpoint Service:
- Acceptance required: no
- Attach the NLB created above
- Supported regions: us-east-1, eu-central-1 (these are the AWS regions Trigger.dev runs in, so the service must be consumable from both)
- Allowed principals:
${awsAccountIds.map((id) => ` - arn:aws:iam::${id}:root`).join("\n") || " - <Trigger.dev AWS account ARN>"}
After creating everything, give me the VPC Endpoint Service name (it looks like com.amazonaws.vpce.<region>.vpce-svc-*) so I can paste it into the Trigger.dev dashboard.`;
return (
<div className="space-y-4">
<InputGroup>
<Label>Resource hostname or IP</Label>
<Input
value={hostname}
onChange={(e) => setHostname(e.target.value)}
placeholder="my-database.abc123.us-east-1.rds.amazonaws.com"
fullWidth
/>
</InputGroup>
<div>
<Label>Ports</Label>
<div className="mt-1 space-y-2">
{ports.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
type="number"
min={1}
max={65535}
value={entry.port}
onChange={(e) => updatePort(index, "port", e.target.value)}
placeholder="Port"
className="w-24"
/>
<select
value={entry.protocol}
onChange={(e) => updatePort(index, "protocol", e.target.value)}
className="rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
>
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
</select>
{ports.length > 1 && (
<button
type="button"
onClick={() => removePort(index)}
className="text-text-dimmed transition hover:text-rose-400"
title="Remove port"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={addPort}
className="text-xs text-indigo-400 transition hover:text-indigo-300"
>
+ Add port
</button>
</div>
</div>
<InputGroup>
<Label>Resource AWS Region</Label>
<Select
variant="tertiary/medium"
value={region}
setValue={(v) => setRegion(v)}
items={AWS_REGIONS}
filter={(item, search) => {
const s = search.toLowerCase();
return item.value.toLowerCase().includes(s) || item.label.toLowerCase().includes(s);
}}
text={(value) => {
const r = AWS_REGIONS.find((r) => r.value === value);
return r ? `${r.value}${r.label}` : value;
}}
placeholder="Select a region"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="text-text-bright">{item.value}</span>
<span className="ml-2 text-text-dimmed">{item.label}</span>
</SelectItem>
))
}
</Select>
</InputGroup>
<div className="rounded-md border border-grid-bright bg-background-deep">
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
<span className="text-xs font-medium text-text-dimmed">AI Prompt</span>
<button
type="button"
onClick={() => navigator.clipboard.writeText(prompt)}
className="text-xs text-text-dimmed transition hover:text-text-bright"
>
Copy
</button>
</div>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap p-3 text-xs text-text-dimmed">
{prompt}
</pre>
</div>
</div>
);
}
export default function Page() {
const { availableRegions, activeRegions, awsAccountIds } = useTypedLoaderData<typeof loader>();
const { organizationSlug } = useParams();
const lastSubmission = useActionData();
const [setupMethod, setSetupMethod] = useState<SetupMethod | null>("manual");
const defaultRegion = "us-east-1";
const [form, { name, endpointServiceName, targetRegion }] = useForm({
id: "create-private-connection",
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<PageContainer>
<NavBar>
<PageTitle
title="Add Private Connection"
backButton={{
to: v3PrivateConnectionsPath({ slug: organizationSlug! }),
text: "Private Connections",
}}
/>
<PageAccessories>
<LinkButton
variant="docs/small"
LeadingIcon={BookOpenIcon}
to={docsPath("private-networking/overview")}
>
Private connection docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={true}>
<MainHorizontallyCenteredContainer className="max-w-3xl">
<div>
<div className="mb-4 border-b border-grid-dimmed pb-3">
<Header2 spacing>Add Private Connection</Header2>
<Paragraph variant="small">
Connect your AWS resources to Trigger.dev task pods via AWS PrivateLink. You'll need
to create a VPC Endpoint Service on your AWS account first.
</Paragraph>
</div>
{/* Setup method cards */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<button
type="button"
onClick={() => setSetupMethod("manual")}
className={`rounded-lg border p-4 text-left transition ${
setupMethod === "manual"
? "border-indigo-500 bg-indigo-500/10"
: "border-grid-dimmed hover:border-border-bright"
}`}
>
<PencilSquareIcon className="mb-2 h-5 w-5 text-indigo-400" />
<div className="text-sm font-medium text-text-bright">I have my details</div>
<div className="mt-1 text-xs text-text-dimmed">
Already have your VPC Endpoint Service name
</div>
</button>
<button
type="button"
onClick={() => setSetupMethod("ai")}
className={`rounded-lg border p-4 text-left transition ${
setupMethod === "ai"
? "border-indigo-500 bg-indigo-500/10"
: "border-grid-dimmed hover:border-border-bright"
}`}
>
<SparklesIcon className="mb-2 h-5 w-5 text-purple-400" />
<div className="text-sm font-medium text-text-bright">Set up with AI</div>
<div className="mt-1 text-xs text-text-dimmed">
Generate a prompt for your AI assistant
</div>
</button>
<button
type="button"
onClick={() => setSetupMethod("terraform")}
className={`rounded-lg border p-4 text-left transition ${
setupMethod === "terraform"
? "border-indigo-500 bg-indigo-500/10"
: "border-grid-dimmed hover:border-border-bright"
}`}
>
<CommandLineIcon className="mb-2 h-5 w-5 text-emerald-400" />
<div className="text-sm font-medium text-text-bright">Set up with Terraform</div>
<div className="mt-1 text-xs text-text-dimmed">
Generate a customized Terraform script
</div>
</button>
<button
type="button"
onClick={() => setSetupMethod("docs")}
className={`rounded-lg border p-4 text-left transition ${
setupMethod === "docs"
? "border-indigo-500 bg-indigo-500/10"
: "border-grid-dimmed hover:border-border-bright"
}`}
>
<DocumentTextIcon className="mb-2 h-5 w-5 text-amber-400" />
<div className="text-sm font-medium text-text-bright">Step-by-step guide</div>
<div className="mt-1 text-xs text-text-dimmed">
Visual instructions for the AWS Console
</div>
</button>
</div>
{/* AI prompt wizard */}
{setupMethod === "ai" && (
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
<Header3 spacing>AI-Assisted Setup</Header3>
<Paragraph variant="small" className="mb-4">
Fill in your resource details below and we'll generate a prompt you can paste into
Claude, ChatGPT, or any AI assistant with AWS access. After it creates the
resources, paste the VPC Endpoint Service name below.
</Paragraph>
<AIPromptWizard awsAccountIds={awsAccountIds} />
</div>
)}
{/* Terraform wizard (expandable) */}
{setupMethod === "terraform" && (
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
<Header3 spacing>Terraform Configuration</Header3>
<Paragraph variant="small" className="mb-4">
Fill in your resource details below and we'll generate a Terraform script. Run{" "}
<code className="rounded bg-background-bright px-1 py-0.5 text-xs">
terraform apply
</code>{" "}
to create the VPC Endpoint Service, then paste the output service name below.
</Paragraph>
<TerraformWizard awsAccountIds={awsAccountIds} />
</div>
)}
{/* Docs link */}
{setupMethod === "docs" && (
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
<Header3 spacing>Setup Guide</Header3>
{awsAccountIds.length > 0 && (
<>
<Paragraph variant="small" className="mb-3">
When adding allowed principals to your VPC Endpoint Service, use the following
AWS account ARN(s):
</Paragraph>
<div className="mb-4 space-y-2">
{awsAccountIds.map((id) => (
<ClipboardField
key={id}
value={`arn:aws:iam::${id}:root`}
variant="primary/medium"
/>
))}
</div>
</>
)}
<Paragraph variant="small" className="mb-3">
Follow the step-by-step guide in our docs to create a VPC Endpoint Service in the
AWS Console, then come back here to add the connection.
</Paragraph>
<LinkButton
to={docsPath("private-networking/aws-console-setup")}
variant="primary/medium"
TrailingIcon={ArrowTopRightOnSquareIcon}
>
Open setup guide
</LinkButton>
</div>
)}
{/* AWS account ARNs reference */}
{setupMethod === "manual" && awsAccountIds.length > 0 && (
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
<Paragraph variant="small" className="mb-3">
Add the following AWS account ARN(s) to your VPC Endpoint Service's allowed
principals:
</Paragraph>
<div className="space-y-2">
{awsAccountIds.map((id) => (
<ClipboardField
key={id}
value={`arn:aws:iam::${id}:root`}
variant="primary/medium"
/>
))}
</div>
</div>
)}
{/* Connection form (always visible) */}
<div className="rounded-lg border border-grid-dimmed p-4">
<Header3 spacing>Connection Details</Header3>
<Form method="post" {...getFormProps(form)}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={name.id} required>
Friendly name
</Label>
<Input
{...getInputProps(name, { type: "text" })}
placeholder="e.g., Production Database, Redis Cache"
fullWidth
/>
<FormError id={name.errorId}>{name.errors}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={endpointServiceName.id} required>
VPC Endpoint Service name
</Label>
<Input
{...getInputProps(endpointServiceName, { type: "text" })}
placeholder="com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0"
fullWidth
/>
<FormError id={endpointServiceName.errorId}>
{endpointServiceName.errors}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={targetRegion.id} required>
Target region
</Label>
<select
{...getSelectProps(targetRegion)}
defaultValue={defaultRegion}
className="w-full rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
>
{availableRegions.map((region: string) => (
<option key={region} value={region}>
{region}
</option>
))}
</select>
<FormError id={targetRegion.errorId}>{targetRegion.errors}</FormError>
{activeRegions.length > 0 && (
<Paragraph variant="extra-small" className="text-text-dimmed">
Your tasks have recently run in: {activeRegions.join(", ")}
</Paragraph>
)}
</InputGroup>
<FormButtons
cancelButton={
<LinkButton variant="tertiary/small" to="..">
Cancel
</LinkButton>
}
confirmButton={
<Button type="submit" variant="primary/small">
Create Connection
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}