From 719ccfd49810ef5611af613a5898c808b58dcb06 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 5 Aug 2025 18:45:36 +0100 Subject: [PATCH 01/16] Initial Regions page --- .../app/assets/icons/CloudProviderIcon.tsx | 89 ++++++++ apps/webapp/app/assets/icons/RegionIcons.tsx | 213 ++++++++++++++++++ apps/webapp/app/components/CloudProvider.tsx | 10 + .../app/components/navigation/SideMenu.tsx | 13 +- .../presenters/v3/RegionsPresenter.server.ts | 114 ++++++++++ .../route.tsx | 198 ++++++++++++++++ apps/webapp/app/utils/pathBuilder.ts | 8 + .../migration.sql | 5 + .../database/prisma/schema.prisma | 6 + 9 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/assets/icons/CloudProviderIcon.tsx create mode 100644 apps/webapp/app/assets/icons/RegionIcons.tsx create mode 100644 apps/webapp/app/components/CloudProvider.tsx create mode 100644 apps/webapp/app/presenters/v3/RegionsPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx create mode 100644 internal-packages/database/prisma/migrations/20250805161650_worker_instance_group_added_cloud_provider_location_static_ips_columns/migration.sql diff --git a/apps/webapp/app/assets/icons/CloudProviderIcon.tsx b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx new file mode 100644 index 0000000000..733bdeec62 --- /dev/null +++ b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx @@ -0,0 +1,89 @@ +export function CloudProviderIcon({ + provider, + className, +}: { + provider: "aws" | "digitalocean" | (string & {}); + className?: string; +}) { + switch (provider) { + case "aws": + return ; + case "digitalocean": + return ; + default: + return null; + } +} + +export function AWS({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export function DigitalOcean({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RegionIcons.tsx b/apps/webapp/app/assets/icons/RegionIcons.tsx new file mode 100644 index 0000000000..ef77986443 --- /dev/null +++ b/apps/webapp/app/assets/icons/RegionIcons.tsx @@ -0,0 +1,213 @@ +export function FlagIcon({ + region, + className, +}: { + region: "usa" | "europe" | (string & {}); + className?: string; +}) { + switch (region) { + case "usa": + return ; + case "europe": + return ; + default: + return null; + } +} + +export function FlagUSA({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function FlagEurope({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/CloudProvider.tsx b/apps/webapp/app/components/CloudProvider.tsx new file mode 100644 index 0000000000..acf8cff550 --- /dev/null +++ b/apps/webapp/app/components/CloudProvider.tsx @@ -0,0 +1,10 @@ +export function cloudProviderTitle(provider: "aws" | "digitalocean" | (string & {})) { + switch (provider) { + case "aws": + return "Amazon Web Services"; + case "digitalocean": + return "Digital Ocean"; + default: + return provider; + } +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 20d2a821db..4e0adf9794 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -10,6 +10,7 @@ import { CogIcon, FolderIcon, FolderOpenIcon, + GlobeAmericasIcon, IdentificationIcon, KeyIcon, PlusIcon, @@ -22,6 +23,7 @@ import { useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import simplur from "simplur"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; @@ -46,6 +48,7 @@ import { organizationPath, organizationSettingsPath, organizationTeamPath, + regionsPath, v3ApiKeysPath, v3BatchesPath, v3BillingPath, @@ -87,8 +90,6 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; -import { ListChecks } from "lucide-react"; -import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -311,6 +312,14 @@ export function SideMenu({ data-action="preview-branches" badge={} /> + } + /> ({ + id: region.id, + name: region.name, + description: region.description ?? undefined, + cloudProvider: region.cloudProvider ?? undefined, + location: region.location ?? undefined, + staticIPs: region.staticIPs ?? undefined, + isDefault: region.id === defaultWorkerInstanceGroupId, + })); + + if (project.defaultWorkerGroupId) { + const defaultWorkerGroup = await this._replica.workerInstanceGroup.findFirst({ + select: { + id: true, + name: true, + description: true, + cloudProvider: true, + location: true, + staticIPs: true, + }, + where: { id: project.defaultWorkerGroupId }, + }); + + if (defaultWorkerGroup) { + // Unset the default region + const defaultRegion = regions.find((region) => region.isDefault); + if (defaultRegion) { + defaultRegion.isDefault = false; + } + + regions.push({ + id: defaultWorkerGroup.id, + name: defaultWorkerGroup.name, + description: defaultWorkerGroup.description ?? undefined, + cloudProvider: defaultWorkerGroup.cloudProvider ?? undefined, + location: defaultWorkerGroup.location ?? undefined, + staticIPs: defaultWorkerGroup.staticIPs ?? undefined, + isDefault: true, + }); + } + } + + return { + regions, + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx new file mode 100644 index 0000000000..5d0dd61881 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -0,0 +1,198 @@ +import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +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 { V4Title } from "~/components/V4Badge"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { InlineCode } from "~/components/code/InlineCode"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +import * as Property from "~/components/primitives/PropertyTable"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { docsPath, ProjectParamSchema } from "~/utils/pathBuilder"; + +export const RegionsOptions = z.object({ + search: z.string().optional(), + page: z.preprocess((val) => Number(val), z.number()).optional(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam } = ProjectParamSchema.parse(params); + + const searchParams = new URL(request.url).searchParams; + const parsedSearchParams = RegionsOptions.safeParse(Object.fromEntries(searchParams)); + const options = parsedSearchParams.success ? parsedSearchParams.data : {}; + + try { + const presenter = new RegionsPresenter(); + const result = await presenter.call({ + userId, + projectSlug: projectParam, + }); + + return typedjson(result); + } catch (error) { + logger.error("Error loading regions page", { error }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export default function Page() { + const { regions } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + + return ( + + + Regions} /> + + + + {regions.map((region) => ( + + {region.name} + {region.id} + + ))} + + + + + Regions docs + + + + +
+ {regions.length === 0 ? ( + +
+ No regions found for this project. +
+
+ ) : ( + <> +
+ + + + Region + Cloud Provider + Location + Static IPs + + Is default? + + + Actions + + + + + {regions.length === 0 ? ( + + There are no regions for this project + + ) : ( + regions.map((region) => { + return ( + + + + + + {region.cloudProvider ? ( + + + {cloudProviderTitle(region.cloudProvider)} + + ) : ( + "–" + )} + + + + {region.location ? ( + + ) : null} + {region.description ?? "–"} + + + + {region.staticIPs ? ( + + ) : ( + "–" + )} + + + {region.isDefault ? ( + + Default + + ) : ( + "–" + )} + + + } + /> + + ); + }) + )} + +
+
+ + )} +
+
+
+ ); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index cfb77f3437..9ff7ff9c1e 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -453,6 +453,14 @@ export function branchesPath( return `${v3EnvironmentPath(organization, project, environment)}/branches`; } +export function regionsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/regions`; +} + export function v3BillingPath(organization: OrgForPath, message?: string) { return `${organizationPath(organization)}/settings/billing${ message ? `?message=${encodeURIComponent(message)}` : "" diff --git a/internal-packages/database/prisma/migrations/20250805161650_worker_instance_group_added_cloud_provider_location_static_ips_columns/migration.sql b/internal-packages/database/prisma/migrations/20250805161650_worker_instance_group_added_cloud_provider_location_static_ips_columns/migration.sql new file mode 100644 index 0000000000..d1509fea9a --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250805161650_worker_instance_group_added_cloud_provider_location_static_ips_columns/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "WorkerInstanceGroup" +ADD COLUMN "cloudProvider" TEXT, +ADD COLUMN "location" TEXT, +ADD COLUMN "staticIPs" TEXT; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index ce12dcf7c7..d6ee22ac90 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1142,6 +1142,7 @@ model WorkerInstanceGroup { /// If unmanged, it will be prefixed with the project ID e.g. "project_1-us-east-1" masterQueue String @unique + /// "N. Virginia, USA", "Frankfurt, Germany", etc. Used for display purposes description String? hidden Boolean @default(false) @@ -1159,6 +1160,11 @@ model WorkerInstanceGroup { project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String? + cloudProvider String? + /// "usa", "europe", etc. Used like a pseudo enum for things like flags + location String? + staticIPs String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } From 370b0351623f6e3734cee9961faacd5764ade984 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 6 Aug 2025 09:47:41 +0100 Subject: [PATCH 02/16] Fix for bad attribute name --- apps/webapp/app/assets/icons/RegionIcons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/assets/icons/RegionIcons.tsx b/apps/webapp/app/assets/icons/RegionIcons.tsx index ef77986443..491eb2f980 100644 --- a/apps/webapp/app/assets/icons/RegionIcons.tsx +++ b/apps/webapp/app/assets/icons/RegionIcons.tsx @@ -198,8 +198,8 @@ export function FlagEurope({ className }: { className?: string }) { y2="71.9297" gradientUnits="userSpaceOnUse" > - - + + From 12c60f3fbaa51cbbb6a85f1b5a33fe38d3a5598d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 6 Aug 2025 11:14:27 +0100 Subject: [PATCH 03/16] Switching regions is working. Some style improvements --- .../presenters/v3/RegionsPresenter.server.ts | 15 +- .../route.tsx | 131 +++++++++++++----- .../v3/services/setDefaultRegion.server.ts | 30 ++++ 3 files changed, 141 insertions(+), 35 deletions(-) create mode 100644 apps/webapp/app/v3/services/setDefaultRegion.server.ts diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index ebba170d2e..100359bb0a 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -107,8 +107,21 @@ export class RegionsPresenter extends BasePresenter { } } + // Default first + const sorted = regions.sort((a, b) => { + if (a.isDefault) return -1; + if (b.isDefault) return 1; + return a.name.localeCompare(b.name); + }); + + // Remove later duplicates + const unique = sorted.filter((region, index, self) => { + const firstIndex = self.findIndex((t) => t.id === region.id); + return index === firstIndex; + }); + return { - regions, + regions: unique.sort((a, b) => a.name.localeCompare(b.name)), }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 5d0dd61881..6a7c9ed88e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -1,5 +1,7 @@ import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Form } from "@remix-run/react"; +import { ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { CloudProviderIcon } from "~/assets/icons/CloudProviderIcon"; @@ -10,7 +12,7 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { InlineCode } from "~/components/code/InlineCode"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; -import { LinkButton } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { CopyableText } from "~/components/primitives/CopyableText"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; @@ -27,12 +29,21 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { TextLink } from "~/components/primitives/TextLink"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { docsPath, ProjectParamSchema } from "~/utils/pathBuilder"; +import { + docsPath, + EnvironmentParamSchema, + ProjectParamSchema, + regionsPath, +} from "~/utils/pathBuilder"; +import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; export const RegionsOptions = z.object({ search: z.string().optional(), @@ -43,31 +54,68 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam } = ProjectParamSchema.parse(params); - const searchParams = new URL(request.url).searchParams; - const parsedSearchParams = RegionsOptions.safeParse(Object.fromEntries(searchParams)); - const options = parsedSearchParams.success ? parsedSearchParams.data : {}; - - try { - const presenter = new RegionsPresenter(); - const result = await presenter.call({ + const presenter = new RegionsPresenter(); + const [error, result] = await tryCatch( + presenter.call({ userId, projectSlug: projectParam, - }); + }) + ); - return typedjson(result); - } catch (error) { - logger.error("Error loading regions page", { error }); + if (error) { throw new Response(undefined, { status: 400, - statusText: "Something went wrong, if this problem persists please contact support.", + statusText: error.message, }); } + + return typedjson(result); +}; + +const FormSchema = z.object({ + regionId: z.string(), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + const redirectPath = regionsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + + if (!project) { + throw redirectWithErrorMessage(redirectPath, request, "Project not found"); + } + + const formData = await request.formData(); + const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData)); + + if (!parsedFormData.success) { + throw redirectWithErrorMessage(redirectPath, request, "No region specified"); + } + + const service = new SetDefaultRegionService(); + const [error, result] = await tryCatch( + service.call({ + projectId: project.id, + regionId: parsedFormData.data.regionId, + }) + ); + + if (error) { + return redirectWithErrorMessage(redirectPath, request, error.message); + } + + return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`); }; export default function Page() { const { regions } = useTypedLoaderData(); - const organization = useOrganization(); - const project = useProject(); return ( @@ -112,11 +160,20 @@ export default function Page() { Cloud Provider Location Static IPs - - Is default? - - - Actions + + When you trigger a run it will execute in your default region, unless + you{" "} + + specify a region when triggering + + . + + } + > + Default region @@ -163,23 +220,29 @@ export default function Page() { variant={"secondary/small"} /> ) : ( - "–" - )} - - - {region.isDefault ? ( - - Default - - ) : ( - "–" + "Not available" )} + visibleButtons={ + region.isDefault ? ( + + Default + + ) : ( +
+ +
+ ) } /> diff --git a/apps/webapp/app/v3/services/setDefaultRegion.server.ts b/apps/webapp/app/v3/services/setDefaultRegion.server.ts new file mode 100644 index 0000000000..c485f46c57 --- /dev/null +++ b/apps/webapp/app/v3/services/setDefaultRegion.server.ts @@ -0,0 +1,30 @@ +import { BaseService, ServiceValidationError } from "./baseService.server"; + +export class SetDefaultRegionService extends BaseService { + public async call({ projectId, regionId }: { projectId: string; regionId: string }) { + const workerGroup = await this._prisma.workerInstanceGroup.findFirst({ + where: { + id: regionId, + hidden: false, + }, + }); + + if (!workerGroup) { + throw new ServiceValidationError("Region not found or is hidden"); + } + + await this._prisma.project.update({ + where: { + id: projectId, + }, + data: { + defaultWorkerGroupId: regionId, + }, + }); + + return { + id: workerGroup.id, + name: workerGroup.name, + }; + } +} From 3526337bddaaf07a949520ee32a7673eb2661daf Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 6 Aug 2025 11:20:27 +0100 Subject: [PATCH 04/16] Use a dialog for confirmation, not great yet --- .../route.tsx | 75 ++++++++++++------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 6a7c9ed88e..c6c1713896 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -1,7 +1,7 @@ -import { BookOpenIcon } from "@heroicons/react/24/solid"; import { Form } from "@remix-run/react"; -import { ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +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"; @@ -9,15 +9,22 @@ import { FlagIcon } from "~/assets/icons/RegionIcons"; import { cloudProviderTitle } from "~/components/CloudProvider"; import { V4Title } from "~/components/V4Badge"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { InlineCode } from "~/components/code/InlineCode"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Button } 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 { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { PopoverMenuItem } from "~/components/primitives/Popover"; import * as Property from "~/components/primitives/PropertyTable"; import { Table, @@ -30,12 +37,9 @@ import { TableRow, } from "~/components/primitives/Table"; import { TextLink } from "~/components/primitives/TextLink"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; -import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; -import { logger } from "~/services/logger.server"; +import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; import { requireUserId } from "~/services/session.server"; import { docsPath, @@ -132,14 +136,6 @@ export default function Page() { ))} - - - Regions docs - @@ -232,16 +228,7 @@ export default function Page() { Default ) : ( -
- -
+ ) } /> @@ -259,3 +246,37 @@ export default function Page() {
); } + +function SetDefaultDialog({ region }: { region: Region }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + + + Set as default + + + + When you trigger a run it will execute in your default region, unless you{" "} + specify a region when triggering + . + + + + +
+ +
+
+
+
+ ); +} From 2103a73db4d7831da8841f7685e176e72522cc5d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 6 Aug 2025 14:32:37 +0100 Subject: [PATCH 05/16] Added allowedMasterQueues --- .../presenters/v3/RegionsPresenter.server.ts | 13 ++++++++--- .../v3/services/setDefaultRegion.server.ts | 22 +++++++++++++++++-- .../migration.sql | 2 ++ .../database/prisma/schema.prisma | 3 +++ 4 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250806124301_project_allowed_master_queues_column/migration.sql diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 100359bb0a..4348c6f3ce 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -22,6 +22,7 @@ export class RegionsPresenter extends BasePresenter { id: true, organizationId: true, defaultWorkerGroupId: true, + allowedMasterQueues: true, }, where: { slug: projectSlug, @@ -57,9 +58,15 @@ export class RegionsPresenter extends BasePresenter { location: true, staticIPs: true, }, - where: { - hidden: false, - }, + where: + // Hide hidden unless they're allowed to use them + project.allowedMasterQueues.length > 0 + ? { + masterQueue: { in: project.allowedMasterQueues }, + } + : { + hidden: false, + }, orderBy: { name: "asc", }, diff --git a/apps/webapp/app/v3/services/setDefaultRegion.server.ts b/apps/webapp/app/v3/services/setDefaultRegion.server.ts index c485f46c57..2ca51f1054 100644 --- a/apps/webapp/app/v3/services/setDefaultRegion.server.ts +++ b/apps/webapp/app/v3/services/setDefaultRegion.server.ts @@ -5,12 +5,30 @@ export class SetDefaultRegionService extends BaseService { const workerGroup = await this._prisma.workerInstanceGroup.findFirst({ where: { id: regionId, - hidden: false, }, }); if (!workerGroup) { - throw new ServiceValidationError("Region not found or is hidden"); + throw new ServiceValidationError("Region not found"); + } + + const project = await this._prisma.project.findFirst({ + where: { + id: projectId, + }, + }); + + if (!project) { + throw new ServiceValidationError("Project not found"); + } + + // If their project is restricted, only allow them to set default regions that are allowed + if (project.allowedMasterQueues.length > 0) { + if (!project.allowedMasterQueues.includes(workerGroup.masterQueue)) { + throw new ServiceValidationError("You're not allowed to set this region as default"); + } + } else if (workerGroup.hidden) { + throw new ServiceValidationError("This region is not available to you"); } await this._prisma.project.update({ diff --git a/internal-packages/database/prisma/migrations/20250806124301_project_allowed_master_queues_column/migration.sql b/internal-packages/database/prisma/migrations/20250806124301_project_allowed_master_queues_column/migration.sql new file mode 100644 index 0000000000..4a1d3b9403 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250806124301_project_allowed_master_queues_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "allowedMasterQueues" TEXT[] DEFAULT ARRAY[]::TEXT[]; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index d6ee22ac90..11018e20a4 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -335,6 +335,9 @@ model Project { defaultWorkerGroup WorkerInstanceGroup? @relation("ProjectDefaultWorkerGroup", fields: [defaultWorkerGroupId], references: [id]) defaultWorkerGroupId String? + /// The master queues they are allowed to use (impacts what they can set as default and trigger runs with) + allowedMasterQueues String[] @default([]) + environments RuntimeEnvironment[] backgroundWorkers BackgroundWorker[] backgroundWorkerTasks BackgroundWorkerTask[] From 80b984aea9ef35e3e9acaaf02b84156e960b21ff Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 12:01:11 +0100 Subject: [PATCH 06/16] Improves flag icons --- apps/webapp/app/assets/icons/RegionIcons.tsx | 253 +++++------------- .../route.tsx | 5 +- 2 files changed, 74 insertions(+), 184 deletions(-) diff --git a/apps/webapp/app/assets/icons/RegionIcons.tsx b/apps/webapp/app/assets/icons/RegionIcons.tsx index 491eb2f980..098d5bc98c 100644 --- a/apps/webapp/app/assets/icons/RegionIcons.tsx +++ b/apps/webapp/app/assets/icons/RegionIcons.tsx @@ -17,96 +17,48 @@ export function FlagIcon({ export function FlagUSA({ className }: { className?: string }) { return ( - - - - - - - - - - - - - - - - + + + + + + + + + - - - - - - - - - + + @@ -115,99 +67,40 @@ export function FlagUSA({ className }: { className?: string }) { export function FlagEurope({ className }: { className?: string }) { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index c6c1713896..4f65ff47b2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -201,10 +201,7 @@ export default function Page() { {region.location ? ( - + ) : null} {region.description ?? "–"} From 9432179a3ae8d1a3d6adbef2d80841433d8bb4a0 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 13:28:15 +0100 Subject: [PATCH 07/16] Improves the region switch modal with more info --- .../route.tsx | 107 ++++++++++++++++-- 1 file changed, 99 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 4f65ff47b2..305c512e7e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -1,3 +1,4 @@ +import { ArrowRightIcon } 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"; @@ -225,7 +226,7 @@ export default function Page() { Default ) : ( - + ) } /> @@ -244,8 +245,15 @@ export default function Page() { ); } -function SetDefaultDialog({ region }: { region: Region }) { +function SetDefaultDialog({ + regions, + newDefaultRegion, +}: { + regions: Region[]; + newDefaultRegion: Region; +}) { const [isOpen, setIsOpen] = useState(false); + const currentDefaultRegion = regions.find((r) => r.isDefault); return ( @@ -254,21 +262,104 @@ function SetDefaultDialog({ region }: { region: Region }) { - Set as default + Set as default region - When you trigger a run it will execute in your default region, unless you{" "} - specify a region when triggering - . + Are you sure you want to set {newDefaultRegion.name} as your default region? + + +
+
+
+ Current default +
+
+ {currentDefaultRegion?.name ?? "–"} +
+
+ + {currentDefaultRegion?.cloudProvider ? ( + <> + + {cloudProviderTitle(currentDefaultRegion.cloudProvider)} + + ) : ( + "–" + )} + +
+
+ + {currentDefaultRegion?.location ? ( + + ) : null} + {currentDefaultRegion?.description ?? "–"} + +
+
+ + {/* Middle column with arrow */} +
+
+ +
+
+ + {/* Right column */} +
+
+ New default +
+
+ {newDefaultRegion.name} +
+
+ + {newDefaultRegion.cloudProvider ? ( + <> + + {cloudProviderTitle(newDefaultRegion.cloudProvider)} + + ) : ( + "–" + )} + +
+
+ + {newDefaultRegion.location ? ( + + ) : null} + {newDefaultRegion.description ?? "–"} + +
+
+
+ + + Runs triggered from now on will execute in "{newDefaultRegion.name}", unless you{" "} + override when triggering.
-
-
From 1745426b08370e36ac6467bf6852a715abe8eaaa Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 13:40:46 +0100 Subject: [PATCH 08/16] =?UTF-8?q?Adds=20=E2=80=9Csuggest=20a=20region?= =?UTF-8?q?=E2=80=9D=20table=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../route.tsx | 24 +++++++++++++++++-- apps/webapp/app/routes/resources.feedback.ts | 1 + 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 305c512e7e..8565721c32 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -1,4 +1,4 @@ -import { ArrowRightIcon } from "@heroicons/react/20/solid"; +import { ArrowRightIcon, ChatBubbleLeftEllipsisIcon } 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"; @@ -8,6 +8,7 @@ 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 { V4Title } from "~/components/V4Badge"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; @@ -234,6 +235,25 @@ export default function Page() { ); }) )} + + + Suggest a new region + + + + Suggest a region… + + } + defaultValue="region" + /> + + @@ -258,7 +278,7 @@ function SetDefaultDialog({ return ( - + diff --git a/apps/webapp/app/routes/resources.feedback.ts b/apps/webapp/app/routes/resources.feedback.ts index 6ea572a91c..a6271c9d5a 100644 --- a/apps/webapp/app/routes/resources.feedback.ts +++ b/apps/webapp/app/routes/resources.feedback.ts @@ -15,6 +15,7 @@ export const feedbackTypeLabel = { enterprise: "Enterprise enquiry", feedback: "General feedback", concurrency: "Increase my concurrency", + region: "Suggest a new region", }; export type FeedbackType = keyof typeof feedbackTypeLabel; From 98b14b614b3314a24404e039dabd03336134f89e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 13:55:07 +0100 Subject: [PATCH 09/16] New icons for the buttons --- .../route.tsx | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 8565721c32..8621bcb10a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -1,4 +1,4 @@ -import { ArrowRightIcon, ChatBubbleLeftEllipsisIcon } from "@heroicons/react/20/solid"; +import { ArrowRightIcon, ChatBubbleLeftEllipsisIcon, 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"; @@ -235,24 +235,27 @@ export default function Page() { ); }) )} - + Suggest a new region - - - Suggest a region… - - } - defaultValue="region" - /> - + + Suggest a region… + + } + defaultValue="region" + /> + } + /> @@ -278,7 +281,15 @@ function SetDefaultDialog({ return ( - + From 11f491bbf5e6f3103963a635da28e1a40b3161a2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 14:08:56 +0100 Subject: [PATCH 10/16] Improved the tooltip information --- .../route.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 8621bcb10a..7f738af855 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -1,4 +1,9 @@ -import { ArrowRightIcon, ChatBubbleLeftEllipsisIcon, MapPinIcon } from "@heroicons/react/20/solid"; +import { + ArrowRightIcon, + BookOpenIcon, + ChatBubbleLeftEllipsisIcon, + 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"; @@ -13,7 +18,7 @@ import { V4Title } from "~/components/V4Badge"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; -import { Button } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { CopyableText } from "~/components/primitives/CopyableText"; import { @@ -161,14 +166,20 @@ export default function Page() { - When you trigger a run it will execute in your default region, unless - you{" "} - - specify a region when triggering - - . - +
+ + When you trigger a run it will execute in your default region, unless + you override the region when triggering. + + + Read docs + +
} > Default region From ba7181dce7499ff72c4f7ad5a7e4c171be70765c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 14:14:13 +0100 Subject: [PATCH 11/16] =?UTF-8?q?New=20=E2=80=9Csmall=E2=80=9D=20badge=20s?= =?UTF-8?q?tyle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/primitives/Badge.tsx | 2 ++ .../route.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index 861ce1ff04..efdc1b0b00 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -6,6 +6,8 @@ const variants = { "grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-charcoal-750 text-text-bright uppercase whitespace-nowrap", "extra-small": "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 whitespace-nowrap", + small: + "grid place-items-center border border-charcoal-650 rounded-sm px-1.5 h-5 text-xs bg-background-bright text-blue-500 whitespace-nowrap", "outline-rounded": "grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap", rounded: diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 7f738af855..e988b8c754 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -234,7 +234,7 @@ export default function Page() { isSticky visibleButtons={ region.isDefault ? ( - + Default ) : ( From 3718a4c10ee2e847f06d35b17edc46421b7fb834 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 14:20:48 +0100 Subject: [PATCH 12/16] Make the default badge live in its column --- .../app/components/primitives/Badge.tsx | 2 +- .../route.tsx | 29 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index efdc1b0b00..a92957e268 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -7,7 +7,7 @@ const variants = { "extra-small": "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 whitespace-nowrap", small: - "grid place-items-center border border-charcoal-650 rounded-sm px-1.5 h-5 text-xs bg-background-bright text-blue-500 whitespace-nowrap", + "grid place-items-center border border-charcoal-650 rounded-sm px-1 h-5 text-xs bg-background-bright text-blue-500 whitespace-nowrap", "outline-rounded": "grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap", rounded: diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index e988b8c754..1ae940a635 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -229,19 +229,21 @@ export default function Page() { "Not available" )} - - Default - - ) : ( + {region.isDefault ? ( + + + Default + + + ) : ( + - ) - } - /> + } + /> + )}
); }) @@ -252,6 +254,7 @@ export default function Page() { - Are you sure you want to set {newDefaultRegion.name} as your default region? + Are you sure you want to set {newDefaultRegion.name} as your new default region?
From d59dbe30b191d595ba786c3f5b385271cec1163f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 6 Aug 2025 11:23:11 +0100 Subject: [PATCH 13/16] Better DO icon size --- .../app/assets/icons/CloudProviderIcon.tsx | 73 ++++++++----------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/apps/webapp/app/assets/icons/CloudProviderIcon.tsx b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx index 733bdeec62..6c16252824 100644 --- a/apps/webapp/app/assets/icons/CloudProviderIcon.tsx +++ b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx @@ -37,51 +37,38 @@ export function AWS({ className }: { className?: string }) { export function DigitalOcean({ className }: { className?: string }) { return ( - - - - - - - - - - - - + + + + + - - - - - + + From 9a9fb2cf291377a706d887f8313234a06281eb92 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 6 Aug 2025 15:20:55 +0100 Subject: [PATCH 14/16] Remove unused export of regions options --- .../route.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 1ae940a635..7525b9ba8b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -56,11 +56,6 @@ import { } from "~/utils/pathBuilder"; import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; -export const RegionsOptions = z.object({ - search: z.string().optional(), - page: z.preprocess((val) => Number(val), z.number()).optional(), -}); - export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam } = ProjectParamSchema.parse(params); From 20f1e667882a23c71ad9f23a907d0b55fcdad859 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 6 Aug 2025 15:18:40 +0100 Subject: [PATCH 15/16] Show upgrade message for free users to get static IPs --- .../presenters/v3/RegionsPresenter.server.ts | 21 ++++++++++++++----- .../route.tsx | 20 ++++++++++++++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 4348c6f3ce..33314cdd2a 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -1,9 +1,8 @@ -import { type z } from "zod"; -import { type PrismaClient, prisma } from "~/db.server"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; -import { FEATURE_FLAG, flags, makeFlags } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server"; import { BasePresenter } from "./basePresenter.server"; +import { getCurrentPlan } from "~/services/platform.v3.server"; export type Region = { id: string; @@ -11,7 +10,7 @@ export type Region = { description?: string; cloudProvider?: string; location?: string; - staticIPs?: string; + staticIPs?: string | null; isDefault: boolean; }; @@ -122,13 +121,25 @@ export class RegionsPresenter extends BasePresenter { }); // Remove later duplicates - const unique = sorted.filter((region, index, self) => { + let unique = sorted.filter((region, index, self) => { const firstIndex = self.findIndex((t) => t.id === region.id); return index === firstIndex; }); + // Don't show static IPs for free users + // Even if they had the IPs they wouldn't work, but this makes it less confusing + const currentPlan = await getCurrentPlan(project.organizationId); + const isPaying = currentPlan?.v3Subscription.isPaying === true; + if (!isPaying) { + unique = unique.map((region) => ({ + ...region, + staticIPs: region.staticIPs ? null : undefined, + })); + } + return { regions: unique.sort((a, b) => a.name.localeCompare(b.name)), + isPaying, }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 7525b9ba8b..159cb74157 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -1,5 +1,6 @@ import { ArrowRightIcon, + ArrowUpCircleIcon, BookOpenIcon, ChatBubbleLeftEllipsisIcon, MapPinIcon, @@ -44,6 +45,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { TextLink } from "~/components/primitives/TextLink"; +import { useOrganization } from "~/hooks/useOrganizations"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; @@ -53,6 +55,7 @@ import { EnvironmentParamSchema, ProjectParamSchema, regionsPath, + v3BillingPath, } from "~/utils/pathBuilder"; import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; @@ -121,7 +124,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { regions } = useTypedLoaderData(); + const { regions, isPaying } = useTypedLoaderData(); + const organization = useOrganization(); return ( @@ -215,7 +219,19 @@ export default function Page() { - {region.staticIPs ? ( + {region.staticIPs === null ? ( + + Unlock static IPs + + ) : region.staticIPs !== undefined ? ( Date: Wed, 6 Aug 2025 15:54:23 +0100 Subject: [PATCH 16/16] Admins can view all regions and switch at will --- .../presenters/v3/RegionsPresenter.server.ts | 32 +++++++++++++------ .../route.tsx | 21 ++++++++---- .../v3/services/setDefaultRegion.server.ts | 22 +++++++++---- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 33314cdd2a..7f69298774 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -12,10 +12,19 @@ export type Region = { location?: string; staticIPs?: string | null; isDefault: boolean; + isHidden: boolean; }; export class RegionsPresenter extends BasePresenter { - public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) { + public async call({ + userId, + projectSlug, + isAdmin = false, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + isAdmin?: boolean; + }) { const project = await this._replica.project.findFirst({ select: { id: true, @@ -56,16 +65,18 @@ export class RegionsPresenter extends BasePresenter { cloudProvider: true, location: true, staticIPs: true, + hidden: true, }, - where: - // Hide hidden unless they're allowed to use them + where: isAdmin + ? undefined + : // Hide hidden unless they're allowed to use them project.allowedMasterQueues.length > 0 - ? { - masterQueue: { in: project.allowedMasterQueues }, - } - : { - hidden: false, - }, + ? { + masterQueue: { in: project.allowedMasterQueues }, + } + : { + hidden: false, + }, orderBy: { name: "asc", }, @@ -79,6 +90,7 @@ export class RegionsPresenter extends BasePresenter { location: region.location ?? undefined, staticIPs: region.staticIPs ?? undefined, isDefault: region.id === defaultWorkerInstanceGroupId, + isHidden: region.hidden, })); if (project.defaultWorkerGroupId) { @@ -90,6 +102,7 @@ export class RegionsPresenter extends BasePresenter { cloudProvider: true, location: true, staticIPs: true, + hidden: true, }, where: { id: project.defaultWorkerGroupId }, }); @@ -109,6 +122,7 @@ export class RegionsPresenter extends BasePresenter { location: defaultWorkerGroup.location ?? undefined, staticIPs: defaultWorkerGroup.staticIPs ?? undefined, isDefault: true, + isHidden: defaultWorkerGroup.hidden, }); } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 159cb74157..471be9aeba 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -46,10 +46,11 @@ import { } from "~/components/primitives/Table"; import { TextLink } from "~/components/primitives/TextLink"; 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 { requireUserId } from "~/services/session.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema, @@ -60,14 +61,15 @@ import { import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); + const user = await requireUser(request); const { projectParam } = ProjectParamSchema.parse(params); const presenter = new RegionsPresenter(); const [error, result] = await tryCatch( presenter.call({ - userId, + userId: user.id, projectSlug: projectParam, + isAdmin: user.admin || user.isImpersonating, }) ); @@ -86,10 +88,10 @@ const FormSchema = z.object({ }); export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); + const user = await requireUser(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); const redirectPath = regionsPath( { slug: organizationSlug }, @@ -113,6 +115,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { service.call({ projectId: project.id, regionId: parsedFormData.data.regionId, + isAdmin: user.admin || user.isImpersonating, }) ); @@ -126,6 +129,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { export default function Page() { const { regions, isPaying } = useTypedLoaderData(); const organization = useOrganization(); + const isAdmin = useHasAdminAccess(); return ( @@ -162,6 +166,7 @@ export default function Page() { Cloud Provider Location Static IPs + {isAdmin && Admin} + {isAdmin && ( + {region.isHidden ? "Hidden" : "Visible"} + )} {region.isDefault ? ( @@ -259,8 +267,9 @@ export default function Page() { ); }) )} + - + Suggest a new region 0) { - if (!project.allowedMasterQueues.includes(workerGroup.masterQueue)) { - throw new ServiceValidationError("You're not allowed to set this region as default"); + if (!isAdmin) { + if (project.allowedMasterQueues.length > 0) { + if (!project.allowedMasterQueues.includes(workerGroup.masterQueue)) { + throw new ServiceValidationError("You're not allowed to set this region as default"); + } + } else if (workerGroup.hidden) { + throw new ServiceValidationError("This region is not available to you"); } - } else if (workerGroup.hidden) { - throw new ServiceValidationError("This region is not available to you"); } await this._prisma.project.update({