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

503 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowRightIcon,
ArrowUpCircleIcon,
BookOpenIcon,
ChatBubbleLeftEllipsisIcon,
InformationCircleIcon,
MapPinIcon,
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { CloudProviderIcon } from "~/assets/icons/CloudProviderIcon";
import { FlagIcon } from "~/assets/icons/RegionIcons";
import { cloudProviderTitle } from "~/components/CloudProvider";
import { Feedback } from "~/components/Feedback";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { CopyableText } from "~/components/primitives/CopyableText";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useHasAdminAccess } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { requireUser } from "~/services/session.server";
import {
docsPath,
EnvironmentParamSchema,
ProjectParamSchema,
regionsPath,
v3BillingPath,
} from "~/utils/pathBuilder";
import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam } = ProjectParamSchema.parse(params);
const presenter = new RegionsPresenter();
const [error, result] = await tryCatch(
presenter.call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
})
);
if (error) {
throw new Response(undefined, {
status: 400,
statusText: error.message,
});
}
return typedjson(result);
};
const FormSchema = z.object({
regionId: z.string(),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
const redirectPath = regionsPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
if (!project) {
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
}
const formData = await request.formData();
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
if (!parsedFormData.success) {
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
}
const service = new SetDefaultRegionService();
const [error, result] = await tryCatch(
service.call({
projectId: project.id,
regionId: parsedFormData.data.regionId,
isAdmin: user.admin || user.isImpersonating,
})
);
if (error) {
return redirectWithErrorMessage(redirectPath, request, error.message);
}
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
};
export default function Page() {
const { regions, isPaying: _isPaying } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
return (
<PageContainer>
<NavBar>
<PageTitle title="Regions" />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
{regions.map((region) => (
<Property.Item key={region.id}>
<Property.Label>{region.name}</Property.Label>
<Property.Value>{region.id}</Property.Value>
</Property.Item>
))}
</Property.Table>
</AdminDebugTooltip>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[1fr]">
{regions.length === 0 ? (
<MainCenteredContainer className="max-w-md">
<div className="text-center">
<Paragraph>No regions found for this project.</Paragraph>
</div>
</MainCenteredContainer>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Region</TableHeaderCell>
<TableHeaderCell>Cloud Provider</TableHeaderCell>
<TableHeaderCell>
<span className="flex items-center gap-1">
Location
<InfoIconTooltip
content="Region location is where your runs execute, not where your data is stored."
contentClassName="normal-case tracking-normal"
/>
</span>
</TableHeaderCell>
<TableHeaderCell>Static IPs</TableHeaderCell>
{isAdmin && <TableHeaderCell>Admin</TableHeaderCell>}
<TableHeaderCell
alignment="right"
tooltip={
<div className="max-w-48">
<Paragraph variant="small">
When you trigger a run it will execute in your default region, unless
you override the region when triggering.
</Paragraph>
<LinkButton
variant="docs/small"
LeadingIcon={BookOpenIcon}
to={docsPath("triggering#region")}
className="mb-1 mt-3"
>
Read docs
</LinkButton>
</div>
}
>
Default region
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{regions.length === 0 ? (
<TableBlankRow colSpan={5}>
<Paragraph>There are no regions for this project</Paragraph>
</TableBlankRow>
) : (
regions.map((region) => {
return (
<TableRow key={region.id}>
<TableCell isTabbableCell>
<span className="flex items-center gap-2">
<CopyableText value={region.name} />
{region.workloadType === "MICROVM" && (
<Badge variant="small">MicroVM</Badge>
)}
</span>
</TableCell>
<TableCell>
{region.cloudProvider ? (
<span className="flex items-center gap-2">
<CloudProviderIcon
provider={region.cloudProvider}
className="size-6"
/>
{cloudProviderTitle(region.cloudProvider)}
</span>
) : (
""
)}
</TableCell>
<TableCell>
<span className="flex items-center gap-2">
{region.location ? (
<FlagIcon region={region.location} className="size-5" />
) : null}
{region.description ?? ""}
</span>
</TableCell>
<TableCell>
{region.staticIPs === null ? (
<LinkButton
variant="secondary/small"
to={v3BillingPath(
organization,
"Upgrade your plan to unlock static IPs"
)}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Unlock static IPs
</LinkButton>
) : region.staticIPs !== undefined ? (
<ClipboardField
value={region.staticIPs}
variant={"secondary/small"}
/>
) : (
"Not available"
)}
</TableCell>
{isAdmin && (
<TableCell>{region.isHidden ? "Hidden" : "Visible"}</TableCell>
)}
{region.isDefault ? (
<TableCell alignment="right">
<Badge variant="small" className="inline-grid">
Default
</Badge>
</TableCell>
) : (
<TableCellMenu
className="pl-32"
isSticky
hiddenButtons={
<SetDefaultDialog regions={regions} newDefaultRegion={region} />
}
/>
)}
</TableRow>
);
})
)}
<TableRow className="h-12.5">
<TableCell colSpan={isAdmin ? 5 : 4}>
<Paragraph variant="extra-small">Suggest a new region</Paragraph>
</TableCell>
<TableCellMenu
alignment="right"
isSticky
visibleButtons={
<Feedback
button={
<Button
variant="secondary/small"
LeadingIcon={ChatBubbleLeftEllipsisIcon}
leadingIconClassName="text-indigo-500"
>
Suggest a region
</Button>
}
defaultValue="region"
/>
}
/>
</TableRow>
</TableBody>
</Table>
{isManagedCloud && (
<InfoPanel
icon={InformationCircleIcon}
iconClassName="size-4"
variant="minimal"
panelClassName="max-w-full gap-1"
>
<Paragraph variant="extra-small">
Trigger.dev is fully{" "}
<TextLink to="https://security.trigger.dev/gdpr?tab=securityControls&frameworks=gdpr_v1">
GDPR compliant
</TextLink>
. Learn more in our{" "}
<TextLink to="https://security.trigger.dev">security portal</TextLink> or{" "}
<Feedback
button={
<span className="cursor-pointer text-xs text-indigo-500 transition hover:text-indigo-400">
get in touch
</span>
}
defaultValue="feedback"
/>
.
</Paragraph>
</InfoPanel>
)}
</div>
</>
)}
</div>
</PageBody>
</PageContainer>
);
}
function SetDefaultDialog({
regions,
newDefaultRegion,
}: {
regions: Region[];
newDefaultRegion: Region;
}) {
const [isOpen, setIsOpen] = useState(false);
const { isManagedCloud } = useFeatures();
const currentDefaultRegion = regions.find((r) => r.isDefault);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="minimal/small"
LeadingIcon={MapPinIcon}
leadingIconClassName="text-blue-500"
iconSpacing="gap-2"
className="pl-2"
>
<span className="text-text-bright">Set as default</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Set as default region</DialogTitle>
</DialogHeader>
<DialogDescription asChild>
<div>
<Paragraph>
Are you sure you want to set {newDefaultRegion.name} as your new default region?
</Paragraph>
<div className="my-4 flex">
<div className="flex flex-1 flex-col rounded-md border border-grid-dimmed">
<div className="border-b border-grid-dimmed bg-background-bright p-3 font-medium">
<Paragraph variant="small/bright">Current default</Paragraph>
</div>
<div className="border-b border-grid-dimmed p-3">
<Paragraph variant="small">{currentDefaultRegion?.name ?? ""}</Paragraph>
</div>
<div className="border-b border-grid-dimmed p-3">
<Paragraph variant="small" className="flex items-center gap-2">
{currentDefaultRegion?.cloudProvider ? (
<>
<CloudProviderIcon
provider={currentDefaultRegion.cloudProvider}
className="size-6"
/>
{cloudProviderTitle(currentDefaultRegion.cloudProvider)}
</>
) : (
""
)}
</Paragraph>
</div>
<div className="p-3">
<Paragraph variant="small" className="flex items-center gap-2">
{currentDefaultRegion?.location ? (
<FlagIcon region={currentDefaultRegion.location} className="size-5" />
) : null}
{currentDefaultRegion?.description ?? ""}
</Paragraph>
</div>
</div>
{/* Middle column with arrow */}
<div className="flex items-center justify-center px-3">
<div className="flex size-10 items-center justify-center rounded-full border border-grid-dimmed bg-background-bright p-2">
<ArrowRightIcon className="size-4 text-text-dimmed" />
</div>
</div>
{/* Right column */}
<div className="flex flex-1 flex-col rounded-md border border-grid-dimmed">
<div className="border-b border-grid-dimmed bg-background-bright p-3 font-medium">
<Paragraph variant="small/bright">New default</Paragraph>
</div>
<div className="border-b border-grid-dimmed p-3">
<Paragraph variant="small">{newDefaultRegion.name}</Paragraph>
</div>
<div className="border-b border-grid-dimmed p-3">
<Paragraph variant="small" className="flex items-center gap-2">
{newDefaultRegion.cloudProvider ? (
<>
<CloudProviderIcon
provider={newDefaultRegion.cloudProvider}
className="size-6"
/>
{cloudProviderTitle(newDefaultRegion.cloudProvider)}
</>
) : (
""
)}
</Paragraph>
</div>
<div className="p-3">
<Paragraph variant="small" className="flex items-center gap-2">
{newDefaultRegion.location ? (
<FlagIcon region={newDefaultRegion.location} className="size-5" />
) : null}
{newDefaultRegion.description ?? ""}
</Paragraph>
</div>
</div>
</div>
<Paragraph>
Runs triggered from now on will execute in "{newDefaultRegion.name}", unless you{" "}
<TextLink to={docsPath("triggering#region")}>override when triggering</TextLink>.
</Paragraph>
<InfoPanel
icon={InformationCircleIcon}
iconClassName="size-4"
variant="minimal"
panelClassName="mt-4 max-w-full gap-1 border-t border-grid-dimmed pt-4 pb-0 pl-0"
>
<Paragraph variant="extra-small">
Region is where your runs execute, not where your data is stored.
{isManagedCloud ? (
<>
{" "}
Trigger.dev is fully{" "}
<TextLink to="https://security.trigger.dev/gdpr?tab=securityControls&frameworks=gdpr_v1">
GDPR compliant
</TextLink>
.
</>
) : null}
</Paragraph>
</InfoPanel>
</div>
</DialogDescription>
<DialogFooter>
<Button variant="secondary/medium" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Form method="post">
<Button
variant="primary/medium"
type="submit"
name="regionId"
shortcut={{ modifiers: ["mod"], key: "enter" }}
value={newDefaultRegion.id}
>
Set as default
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}