From 7316242af1edbc24eb62b981c97d313a7b4ccad2 Mon Sep 17 00:00:00 2001 From: David C Date: Fri, 2 Oct 2026 16:56:04 -0400 Subject: [PATCH 01/24] feat(admin): manage an organization's custom regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the staff-only Custom regions page (/admin/custom-regions) over central-manager's OrganizationRegion resource: pick an organization, list its custom regions, create or edit one. A custom region is a placement shape — the datacenters one unit occupies per provider, a repeated datacenter meaning another instance there — plus an optional fallback pool and blocks per unit. Fields central-manager freezes once a live cluster deploys the region are disabled in the form; only `active` stays editable. The catalog region form gains the same per-datacenter count summary so both pickers read the same way. The API overlay types gain OrganizationRegion and `quantity` on region plans; `regionLookup` resolves catalog tiers and custom regions into one id-keyed shape the cluster form builds on next. Refs #1778 Co-Authored-By: Claude Fable 5.1 --- .../OrganizationRegionFormSchema.test.ts | 97 +++++ .../OrganizationRegionFormSchema.ts | 80 +++++ .../OrganizationRegionFormModal.tsx | 333 ++++++++++++++++++ .../admin/organizationRegions/index.tsx | 140 ++++++++ .../useOrganizationRegionMutations.ts | 31 ++ .../queries/getOrganizationRegions.ts | 50 +++ .../components/DatacenterCountSummary.tsx | 16 + .../regions/components/RegionFormModal.tsx | 3 + .../clusters/upsert/lib/regionLookup.test.ts | 103 ++++++ .../clusters/upsert/lib/regionLookup.ts | 109 ++++++ src/integrations/api/api.patch.d.ts | 54 ++- 11 files changed, 1014 insertions(+), 2 deletions(-) create mode 100644 src/features/admin/organizationRegions/OrganizationRegionFormSchema.test.ts create mode 100644 src/features/admin/organizationRegions/OrganizationRegionFormSchema.ts create mode 100644 src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx create mode 100644 src/features/admin/organizationRegions/index.tsx create mode 100644 src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts create mode 100644 src/features/admin/organizationRegions/queries/getOrganizationRegions.ts create mode 100644 src/features/admin/regions/components/DatacenterCountSummary.tsx create mode 100644 src/features/clusters/upsert/lib/regionLookup.test.ts create mode 100644 src/features/clusters/upsert/lib/regionLookup.ts diff --git a/src/features/admin/organizationRegions/OrganizationRegionFormSchema.test.ts b/src/features/admin/organizationRegions/OrganizationRegionFormSchema.test.ts new file mode 100644 index 000000000..df54254ea --- /dev/null +++ b/src/features/admin/organizationRegions/OrganizationRegionFormSchema.test.ts @@ -0,0 +1,97 @@ +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; +import { NO_FALLBACK, OrganizationRegionFormSchema, toCreatePayload, toFormValues, toPatch } from './OrganizationRegionFormSchema'; + +const stored: OrganizationRegion = { + id: 'oreg-1', + organizationId: 'org-1', + name: 'EU edge', + placement: { linode: ['fr-par', 'it-mil'], gcp: [] }, + fallbackGroup: 'europe', + blocksPerUnit: 2, + active: true, +}; + +const valid = { + name: 'EU edge', + linodeDatacenters: ['fr-par'], + gcpDatacenters: [], + fallbackGroup: NO_FALLBACK, + blocksPerUnit: 1, + active: true, +}; + +describe('OrganizationRegionFormSchema', () => { + it('accepts a region with one provider populated', () => { + expect(OrganizationRegionFormSchema.safeParse(valid).success).toBe(true); + }); + + it('requires at least one datacenter across providers', () => { + const result = OrganizationRegionFormSchema.safeParse({ ...valid, linodeDatacenters: [] }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['gcpDatacenters']); + } + }); + + it.each([0, 11, 1.5])('rejects %s blocks per unit', (blocksPerUnit) => { + expect(OrganizationRegionFormSchema.safeParse({ ...valid, blocksPerUnit }).success).toBe(false); + }); + + it('trims and requires the name', () => { + expect(OrganizationRegionFormSchema.safeParse({ ...valid, name: ' ' }).success).toBe(false); + const parsed = OrganizationRegionFormSchema.safeParse({ ...valid, name: ' EU ' }); + expect(parsed.success && parsed.data.name).toBe('EU'); + }); +}); + +describe('toFormValues', () => { + it('maps a stored row and defaults a new one', () => { + expect(toFormValues(stored)).toEqual({ + name: 'EU edge', + linodeDatacenters: ['fr-par', 'it-mil'], + gcpDatacenters: [], + fallbackGroup: 'europe', + blocksPerUnit: 2, + active: true, + }); + expect(toFormValues(null)).toEqual({ + name: '', + linodeDatacenters: [], + gcpDatacenters: [], + fallbackGroup: NO_FALLBACK, + blocksPerUnit: 1, + active: true, + }); + }); +}); + +describe('toCreatePayload', () => { + it('stores the no-fallback choice as null', () => { + expect(toCreatePayload('org-1', valid)).toEqual({ + organizationId: 'org-1', + name: 'EU edge', + placement: { linode: ['fr-par'], gcp: [] }, + fallbackGroup: null, + blocksPerUnit: 1, + active: true, + }); + }); +}); + +describe('toPatch', () => { + it('sends only the fields that changed', () => { + expect(toPatch(stored, toFormValues(stored))).toEqual({}); + expect(toPatch(stored, { ...toFormValues(stored), active: false })).toEqual({ active: false }); + }); + + it('sends the whole placement when either provider list changes', () => { + expect(toPatch(stored, { ...toFormValues(stored), gcpDatacenters: ['europe-west1'] })).toEqual({ + placement: { linode: ['fr-par', 'it-mil'], gcp: ['europe-west1'] }, + }); + }); + + it('clears the fallback pool with null', () => { + expect(toPatch(stored, { ...toFormValues(stored), fallbackGroup: NO_FALLBACK })).toEqual({ fallbackGroup: null }); + }); +}); diff --git a/src/features/admin/organizationRegions/OrganizationRegionFormSchema.ts b/src/features/admin/organizationRegions/OrganizationRegionFormSchema.ts new file mode 100644 index 000000000..4e0abfe66 --- /dev/null +++ b/src/features/admin/organizationRegions/OrganizationRegionFormSchema.ts @@ -0,0 +1,80 @@ +import { OrganizationRegion, OrganizationRegionPatch, OrganizationRegionPayload } from '@/integrations/api/api.patch'; +import { z } from 'zod'; + +/** Fields central-manager freezes once a live cluster deploys the region. */ +export const FROZEN_WHILE_REFERENCED = ['name', 'linodeDatacenters', 'gcpDatacenters', 'fallbackGroup', 'blocksPerUnit'] as const; + +/** The form's "no fallback pool" choice; the API stores null for it. */ +export const NO_FALLBACK = ''; + +/** + * Create/edit form for an organization's custom region. Mirrors the OrganizationRegion contract: a + * name unique within the organization, at least one provider's datacenter list (a repeated datacenter + * asks for another instance there), an optional fallback pool and 1–10 purchased blocks per unit. + */ +export const OrganizationRegionFormSchema = z.object({ + name: z.string().trim().min(1, 'Name is required').max(64, 'Keep the name to 64 characters'), + linodeDatacenters: z.array(z.string()), + gcpDatacenters: z.array(z.string()), + fallbackGroup: z.string(), + blocksPerUnit: z.number({ error: 'Enter a whole number' }).int('Must be a whole number').min(1, 'Must be at least 1').max( + 10, + 'At most 10 blocks per unit', + ), + active: z.boolean(), +}).superRefine((values, ctx) => { + if (!values.linodeDatacenters.length && !values.gcpDatacenters.length) { + ctx.addIssue({ + code: 'custom', + path: ['gcpDatacenters'], + message: 'Add at least one datacenter under Linode or GCP', + }); + } +}); + +export type OrganizationRegionFormValues = z.infer; + +export function toFormValues(region?: OrganizationRegion | null): OrganizationRegionFormValues { + return { + name: region?.name ?? '', + linodeDatacenters: region?.placement?.linode ?? [], + gcpDatacenters: region?.placement?.gcp ?? [], + fallbackGroup: region?.fallbackGroup ?? NO_FALLBACK, + blocksPerUnit: region?.blocksPerUnit ?? 1, + active: region?.active ?? true, + }; +} + +export function toCreatePayload(organizationId: string, values: OrganizationRegionFormValues): OrganizationRegionPayload { + return { + organizationId, + name: values.name, + placement: { linode: values.linodeDatacenters, gcp: values.gcpDatacenters }, + fallbackGroup: values.fallbackGroup === NO_FALLBACK ? null : values.fallbackGroup, + blocksPerUnit: values.blocksPerUnit, + active: values.active, + }; +} + +/** Only the fields that differ from the stored row, so a frozen field is never resubmitted unchanged. */ +export function toPatch(region: OrganizationRegion, values: OrganizationRegionFormValues): OrganizationRegionPatch { + const stored = toFormValues(region); + const patch: OrganizationRegionPatch = {}; + if (values.name !== stored.name) { + patch.name = values.name; + } + const sameList = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]); + if (!sameList(values.linodeDatacenters, stored.linodeDatacenters) || !sameList(values.gcpDatacenters, stored.gcpDatacenters)) { + patch.placement = { linode: values.linodeDatacenters, gcp: values.gcpDatacenters }; + } + if (values.fallbackGroup !== stored.fallbackGroup) { + patch.fallbackGroup = values.fallbackGroup === NO_FALLBACK ? null : values.fallbackGroup; + } + if (values.blocksPerUnit !== stored.blocksPerUnit) { + patch.blocksPerUnit = values.blocksPerUnit; + } + if (values.active !== stored.active) { + patch.active = values.active; + } + return patch; +} diff --git a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx new file mode 100644 index 000000000..805c37e3c --- /dev/null +++ b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx @@ -0,0 +1,333 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle } from '@/components/ui/dialog'; +import { Form } from '@/components/ui/form/Form'; +import { FormControl } from '@/components/ui/form/FormControl'; +import { FormField } from '@/components/ui/form/FormField'; +import { FormItem } from '@/components/ui/form/FormItem'; +import { FormLabel } from '@/components/ui/form/FormLabel'; +import { FormMessage } from '@/components/ui/form/FormMessage'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + useCreateOrganizationRegionMutation, + useUpdateOrganizationRegionMutation, +} from '@/features/admin/organizationRegions/mutations/useOrganizationRegionMutations'; +import { + FROZEN_WHILE_REFERENCED, + NO_FALLBACK, + OrganizationRegionFormSchema, + OrganizationRegionFormValues, + toCreatePayload, + toFormValues, + toPatch, +} from '@/features/admin/organizationRegions/OrganizationRegionFormSchema'; +import { + getOrganizationRegionQueryOptions, + organizationRegionQueryKey, + organizationRegionsQueryKey, +} from '@/features/admin/organizationRegions/queries/getOrganizationRegions'; +import { DatacenterCountSummary } from '@/features/admin/regions/components/DatacenterCountSummary'; +import { MultiSelect, MultiSelectOption } from '@/features/admin/regions/components/MultiSelect'; +import { getLocationsQueryOptions } from '@/features/admin/regions/queries/getLocations'; +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +/** The literal the Select uses for "no fallback": Radix rejects an empty-string item value. */ +const FORCED_OPTION = '__forced__'; + +interface OrganizationRegionFormModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + /** The region being edited; omit/null to create a new one. */ + region?: OrganizationRegion | null; + /** Called with the created or updated row, after the caches are invalidated. */ + onSaved?: (region: OrganizationRegion) => void; +} + +function describeApiError(error: unknown): string { + if (isAxiosError(error)) { + const title = (error.response?.data as { title?: string } | undefined)?.title; + if (title) { + return title; + } + } + return error instanceof Error ? error.message : 'Request failed'; +} + +export function OrganizationRegionFormModal( + { open, onOpenChange, organizationId, region, onSaved }: OrganizationRegionFormModalProps, +) { + const isEdit = !!region; + const queryClient = useQueryClient(); + + const { data: locations = [] } = useQuery({ ...getLocationsQueryOptions(), enabled: open }); + // The list row carries no `clusters`; the by-id read does, and it decides which fields are frozen. + const { data: regionWithClusters } = useQuery({ ...getOrganizationRegionQueryOptions(region?.id), enabled: open && isEdit }); + const referencingClusters = regionWithClusters?.clusters ?? []; + const frozen = referencingClusters.length > 0; + + const { mutate: createRegion, isPending: isCreating } = useCreateOrganizationRegionMutation(); + const { mutate: updateRegion, isPending: isUpdating } = useUpdateOrganizationRegionMutation(); + const isPending = isCreating || isUpdating; + + const linodeOptions = useMemo( + () => + locations + .filter((l) => l.cloudProvider === 'linode') + .map((l) => ({ value: l.location, label: `${l.locationName} (${l.location})` })), + [locations], + ); + const gcpOptions = useMemo( + () => + locations + .filter((l) => l.cloudProvider === 'gcp') + .map((l) => ({ value: l.location, label: `${l.locationName} (${l.location})` })), + [locations], + ); + const fallbackOptions = useMemo(() => { + const names = new Set(); + for (const l of locations) { for (const r of l.regions ?? []) { names.add(r); } } + if (region?.fallbackGroup) { names.add(region.fallbackGroup); } + return [...names].sort(); + }, [locations, region]); + const regionsByDatacenter = useMemo(() => new Map(locations.map((l) => [l.location, l.regions ?? []])), [locations]); + + const form = useForm({ + resolver: zodResolver(OrganizationRegionFormSchema), + defaultValues: toFormValues(region), + }); + + useEffect(() => { + if (open) { form.reset(toFormValues(region)); } + }, [open, region, form]); + + const linodeDatacenters = form.watch('linodeDatacenters'); + const gcpDatacenters = form.watch('gcpDatacenters'); + + const onSubmit = (values: OrganizationRegionFormValues) => { + // Mirrors central-manager: a fallback pool must cover every listed datacenter, or a pooled deploy + // could never reach the shape's own datacenters. + if (values.fallbackGroup !== NO_FALLBACK) { + const outside = [...values.linodeDatacenters, ...values.gcpDatacenters].find((dc) => + !regionsByDatacenter.get(dc)?.includes(values.fallbackGroup) + ); + if (outside) { + form.setError('fallbackGroup', { message: `${outside} is not in region ${values.fallbackGroup}` }); + return; + } + } + + const onSuccess = (saved: OrganizationRegion) => { + toast.success(isEdit ? 'Custom region updated' : 'Custom region created'); + void queryClient.invalidateQueries({ queryKey: organizationRegionsQueryKey(organizationId) }); + if (region) { + void queryClient.invalidateQueries({ queryKey: organizationRegionQueryKey(region.id) }); + } + onOpenChange(false); + onSaved?.(saved); + }; + const onError = (error: unknown) => { + const message = describeApiError(error); + const status = isAxiosError(error) ? error.response?.status : undefined; + // 409 is either the name collision or the freeze; both belong on the form, not a toast. + form.setError(status === 409 && !frozen ? 'name' : 'root', { message }); + }; + + if (region) { + const changes = toPatch(region, values); + if (Object.keys(changes).length === 0) { + onOpenChange(false); + return; + } + updateRegion({ id: region.id, changes }, { onSuccess, onError }); + } else { + createRegion(toCreatePayload(organizationId, values), { onSuccess, onError }); + } + }; + + const isFrozen = (field: (typeof FROZEN_WHILE_REFERENCED)[number]) => frozen && FROZEN_WHILE_REFERENCED.includes(field); + + return ( + + + {isEdit ? 'Edit custom region' : 'Create custom region'} + + {isEdit + ? 'Update this custom region.' + : 'Define the datacenters one unit of this region occupies. A cluster chooses how many units it wants.'} + + {frozen && ( +

+ Deployed by {referencingClusters.map((c) => `${c.name} (×${c.quantity})`).join(', ')}. The name, datacenters, + fallback and blocks per unit are fixed while a cluster runs on this region; create a new region to change them. +

+ )} +
+ + ( + + Name + + + + + + )} + /> +
+ ( + + Linode datacenters + + + + + + + )} + /> + ( + + GCP datacenters + + + + + + + )} + /> +
+

+ Pick a datacenter again to place another instance there. The organization's cloud provider decides which + list is used. +

+
+ ( + + Fallback pool + + + +

+ Without a pool, a datacenter with no capacity fails the deploy instead of landing elsewhere. +

+ +
+ )} + /> + ( + + Purchased blocks per unit + + field.onChange(e.target.valueAsNumber)} + disabled={isFrozen('blocksPerUnit')} + /> + + + + )} + /> +
+ ( + +
+ + field.onChange(e.target.checked)} + /> + + Active +
+

+ An inactive region stays on the clusters that use it but isn't offered for new deployments. +

+ +
+ )} + /> + {form.formState.errors.root && ( +

{form.formState.errors.root.message}

+ )} + +
+ + +
+
+ + +
+
+ ); +} diff --git a/src/features/admin/organizationRegions/index.tsx b/src/features/admin/organizationRegions/index.tsx new file mode 100644 index 000000000..64232cfe2 --- /dev/null +++ b/src/features/admin/organizationRegions/index.tsx @@ -0,0 +1,140 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { OrganizationRegionFormModal } from '@/features/admin/organizationRegions/components/OrganizationRegionFormModal'; +import { getOrganizationRegionsQueryOptions } from '@/features/admin/organizationRegions/queries/getOrganizationRegions'; +import { formatOrgLabel, getOrganizationsQueryOptions } from '@/features/admin/regions/queries/getOrganizations'; +import { describeShape } from '@/features/clusters/upsert/lib/regionLookup'; +import { useStaffPermission } from '@/hooks/useAuth'; +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { useQuery } from '@tanstack/react-query'; +import { PencilIcon, PlusIcon } from 'lucide-react'; +import { useState } from 'react'; + +/** "Linode: fr-par ×2 · it-mil" per populated provider, one line each. */ +export function PlacementSummary({ placement }: { placement: OrganizationRegion['placement'] }) { + const lines = [ + placement?.linode?.length ? `Linode: ${describeShape(placement.linode)}` : null, + placement?.gcp?.length ? `GCP: ${describeShape(placement.gcp)}` : null, + ].filter((line): line is string => line !== null); + return ( +
+ {lines.map((line) => {line})} +
+ ); +} + +export function OrganizationRegionsIndex() { + const [organizationId, setOrganizationId] = useState(''); + const { data: orgResult, isLoading: orgsLoading } = useQuery(getOrganizationsQueryOptions()); + const { data: regions, isLoading, isError } = useQuery(getOrganizationRegionsQueryOptions(organizationId || undefined)); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const canWriteRegions = useStaffPermission('region:write'); + + const organizations = orgResult?.organizations ?? []; + + const openCreate = () => { + setEditing(null); + setModalOpen(true); + }; + const openEdit = (region: OrganizationRegion) => { + setEditing(region); + setModalOpen(true); + }; + + return ( +
+
+
+

Custom regions

+

+ Placement shapes owned by one organization: the datacenters one unit occupies. Every cluster in the + organization can deploy one, choosing how many units it wants. Internal only — customers never see these. +

+
+ {canWriteRegions && organizationId && ( + + )} +
+ +
+ + {orgResult?.truncated && ( +

+ Too many organizations to list them all — some may be missing from this picker. +

+ )} +
+ +
+ {!organizationId + ?

Choose an organization to see its custom regions.

+ : isLoading + ?

Loading custom regions…

+ : isError + ?

Couldn't load custom regions.

+ : !regions || regions.length === 0 + ?

No custom regions yet for this organization.

+ : ( + + + + Name + Datacenters per unit + Fallback pool + Blocks / unit + + + + + {regions.map((region) => ( + + + + {region.name} + {region.active === false && Inactive} + +
{region.id}
+
+ + + + {region.fallbackGroup ?? 'None (forced)'} + {region.blocksPerUnit} + + {canWriteRegions && ( + + )} + +
+ ))} +
+
+ )} +
+ + {organizationId && ( + + )} +
+ ); +} diff --git a/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts b/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts new file mode 100644 index 000000000..f863535a2 --- /dev/null +++ b/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts @@ -0,0 +1,31 @@ +import { apiClient } from '@/config/apiClient'; +import { OrganizationRegion, OrganizationRegionPatch, OrganizationRegionPayload } from '@/integrations/api/api.patch'; +import { useMutation } from '@tanstack/react-query'; + +/** POST /OrganizationRegion/ → create a custom region for an organization (region:write). */ +export async function createOrganizationRegion(payload: OrganizationRegionPayload): Promise { + const { data } = await apiClient.post('/OrganizationRegion/' as '/Region/', payload as never); + return data as unknown as OrganizationRegion; +} + +/** + * PATCH /OrganizationRegion/:id (region:write). Send only what changed: while a live cluster references + * the row, central-manager refuses any change to name, placement, fallbackGroup or blocksPerUnit (409), + * so resubmitting an unchanged frozen field would turn an `active` toggle into a refusal. + */ +export async function updateOrganizationRegion( + { id, changes }: { id: string; changes: OrganizationRegionPatch }, +): Promise { + const { data } = await apiClient.patch(`/OrganizationRegion/${encodeURIComponent(id)}` as '/Region/{id}', changes as never); + return data as unknown as OrganizationRegion; +} + +export function useCreateOrganizationRegionMutation() { + return useMutation({ mutationFn: createOrganizationRegion }); +} + +export function useUpdateOrganizationRegionMutation() { + return useMutation({ + mutationFn: updateOrganizationRegion, + }); +} diff --git a/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts b/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts new file mode 100644 index 000000000..3373f2ec1 --- /dev/null +++ b/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts @@ -0,0 +1,50 @@ +import { apiClient } from '@/config/apiClient'; +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { queryOptions } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; + +/** + * GET /OrganizationRegion/?organizationId= → the organization's custom regions (members and staff). + * A central-manager that predates the resource answers 404; that reads as "none", so a catalog-only + * cluster form keeps working across the rollout. + */ +export async function getOrganizationRegions(organizationId: string): Promise { + try { + const { data } = await apiClient.get( + `/OrganizationRegion/?organizationId=${encodeURIComponent(organizationId)}` as '/Region/', + ); + return data as unknown as OrganizationRegion[]; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + return []; + } + throw error; + } +} + +/** GET /OrganizationRegion/:id → one row plus the live clusters that reference it. */ +export async function getOrganizationRegion(id: string): Promise { + const { data } = await apiClient.get(`/OrganizationRegion/${encodeURIComponent(id)}` as '/Region/{id}'); + return data as unknown as OrganizationRegion; +} + +export const organizationRegionsQueryKey = (organizationId: string) => ['organizationRegions', organizationId]; +export const organizationRegionQueryKey = (id: string) => ['organizationRegion', id]; + +export function getOrganizationRegionsQueryOptions(organizationId: string | undefined) { + return queryOptions({ + queryKey: organizationRegionsQueryKey(organizationId ?? ''), + queryFn: () => getOrganizationRegions(organizationId!), + enabled: !!organizationId, + retry: false, + }); +} + +export function getOrganizationRegionQueryOptions(id: string | undefined) { + return queryOptions({ + queryKey: organizationRegionQueryKey(id ?? ''), + queryFn: () => getOrganizationRegion(id!), + enabled: !!id, + retry: false, + }); +} diff --git a/src/features/admin/regions/components/DatacenterCountSummary.tsx b/src/features/admin/regions/components/DatacenterCountSummary.tsx new file mode 100644 index 000000000..409137d4e --- /dev/null +++ b/src/features/admin/regions/components/DatacenterCountSummary.tsx @@ -0,0 +1,16 @@ +import { describeShape } from '@/features/clusters/upsert/lib/regionLookup'; + +/** + * The per-datacenter counts a repeated-location list amounts to ("fr-par ×2 · it-mil"). The pickers + * allow repeats but render one chip per entry, which is why the resulting count was never visible. + */ +export function DatacenterCountSummary({ datacenters, ariaLabel }: { datacenters: readonly string[]; ariaLabel: string }) { + if (datacenters.length === 0) { + return null; + } + return ( +

+ {datacenters.length} {datacenters.length === 1 ? 'instance' : 'instances'}: {describeShape(datacenters)} +

+ ); +} diff --git a/src/features/admin/regions/components/RegionFormModal.tsx b/src/features/admin/regions/components/RegionFormModal.tsx index 0501abfa7..fde9b7391 100644 --- a/src/features/admin/regions/components/RegionFormModal.tsx +++ b/src/features/admin/regions/components/RegionFormModal.tsx @@ -9,6 +9,7 @@ import { FormMessage } from '@/components/ui/form/FormMessage'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { DatacenterCountSummary } from '@/features/admin/regions/components/DatacenterCountSummary'; import { MultiSelect, MultiSelectOption } from '@/features/admin/regions/components/MultiSelect'; import { useCreateRegionMutation } from '@/features/admin/regions/mutations/useCreateRegion'; import { useUpdateRegionMutation } from '@/features/admin/regions/mutations/useUpdateRegion'; @@ -274,6 +275,7 @@ export function RegionFormModal({ open, onOpenChange, region }: RegionFormModalP allowRepeats /> + )} @@ -295,6 +297,7 @@ export function RegionFormModal({ open, onOpenChange, region }: RegionFormModalP allowRepeats /> + )} diff --git a/src/features/clusters/upsert/lib/regionLookup.test.ts b/src/features/clusters/upsert/lib/regionLookup.test.ts new file mode 100644 index 000000000..971d9150b --- /dev/null +++ b/src/features/clusters/upsert/lib/regionLookup.test.ts @@ -0,0 +1,103 @@ +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { SchemaRegion } from '@/integrations/api/api.gen'; +import { describe, expect, it } from 'vitest'; +import { + buildRegionLookup, + describeShape, + isOrganizationRegionId, + organizationRegionShape, + regionAtQuantity, + regionCohortKey, +} from './regionLookup'; + +const catalog: SchemaRegion[] = [ + { id: 'us-1', region: 'US', latencyDescription: 'Low (1 region)', instanceCount: 2, purchasedBlockMultiplier: 1 }, + { id: 'us-2', region: 'US', latencyDescription: 'Lower (2 regions)', instanceCount: 4, purchasedBlockMultiplier: 2 }, +]; + +const orgRegion: OrganizationRegion = { + id: 'oreg-1', + organizationId: 'org-1', + name: 'EU edge', + placement: { linode: ['fr-par', 'fr-par', 'it-mil'], gcp: ['europe-west1'] }, + fallbackGroup: null, + blocksPerUnit: 2, + active: true, +}; + +describe('isOrganizationRegionId', () => { + it('recognises the oreg- prefix only', () => { + expect(isOrganizationRegionId('oreg-1')).toBe(true); + expect(isOrganizationRegionId('us-1')).toBe(false); + expect(isOrganizationRegionId(undefined)).toBe(false); + }); +}); + +describe('organizationRegionShape', () => { + it('reads the provider list, defaulting to gcp', () => { + expect(organizationRegionShape(orgRegion, 'linode')).toEqual(['fr-par', 'fr-par', 'it-mil']); + expect(organizationRegionShape(orgRegion, undefined)).toEqual(['europe-west1']); + expect(organizationRegionShape({ ...orgRegion, placement: {} }, 'linode')).toEqual([]); + }); +}); + +describe('buildRegionLookup', () => { + const lookup = buildRegionLookup(catalog, [orgRegion], 'linode'); + + it('keys catalog tiers and custom regions by id', () => { + expect(lookup.get('us-2')).toMatchObject({ kind: 'catalog', name: 'US', instanceCount: 4, blocksPerUnit: 2 }); + expect(lookup.get('oreg-1')).toMatchObject({ + kind: 'organization', + name: 'EU edge', + shape: ['fr-par', 'fr-par', 'it-mil'], + instanceCount: 3, + blocksPerUnit: 2, + }); + }); + + it('treats a missing active flag as active', () => { + const withoutActive: OrganizationRegion = { ...orgRegion }; + delete withoutActive.active; + const built = buildRegionLookup([], [withoutActive], 'linode'); + expect(built.get('oreg-1')?.active).toBe(true); + expect(buildRegionLookup([], [{ ...orgRegion, active: false }], 'linode').get('oreg-1')?.active).toBe(false); + }); + + it('tolerates undefined inputs', () => { + expect(buildRegionLookup(undefined, undefined, undefined).size).toBe(0); + }); +}); + +describe('regionCohortKey', () => { + const lookup = buildRegionLookup(catalog, [orgRegion], 'linode'); + + it('groups catalog tiers by family name and custom regions by id', () => { + expect(regionCohortKey(lookup.get('us-1')!)).toBe(regionCohortKey(lookup.get('us-2')!)); + expect(regionCohortKey(lookup.get('oreg-1')!)).toBe('oreg-1'); + }); +}); + +describe('regionAtQuantity', () => { + const lookup = buildRegionLookup(catalog, [orgRegion], 'linode'); + + it('scales instances and blocks by the quantity', () => { + expect(regionAtQuantity(lookup.get('oreg-1')!, 3)).toMatchObject({ + id: 'oreg-1', + region: 'EU edge', + latencyDescription: 'fr-par ×2 · it-mil', + instanceCount: 9, + purchasedBlockMultiplier: 6, + }); + }); + + it('leaves a catalog tier at one unit', () => { + expect(regionAtQuantity(lookup.get('us-2')!, undefined)).toMatchObject({ instanceCount: 4, purchasedBlockMultiplier: 2 }); + }); +}); + +describe('describeShape', () => { + it('counts repeated datacenters', () => { + expect(describeShape(['fr-par', 'fr-par', 'it-mil'])).toBe('fr-par ×2 · it-mil'); + expect(describeShape([])).toBe(''); + }); +}); diff --git a/src/features/clusters/upsert/lib/regionLookup.ts b/src/features/clusters/upsert/lib/regionLookup.ts new file mode 100644 index 000000000..c7a9a61c7 --- /dev/null +++ b/src/features/clusters/upsert/lib/regionLookup.ts @@ -0,0 +1,109 @@ +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { SchemaCloudInstanceTypes, SchemaRegion } from '@/integrations/api/api.gen'; + +const ORGANIZATION_REGION_ID_PREFIX = 'oreg-'; + +export function isOrganizationRegionId(regionId: string | undefined | null): boolean { + return typeof regionId === 'string' && regionId.startsWith(ORGANIZATION_REGION_ID_PREFIX); +} + +/** How many instances per quantity unit a region plan may ask for (central-manager's MAX_QUANTITY). */ +export const MAX_REGION_PLAN_QUANTITY = 50; + +/** Central-manager refuses a cluster that deploys a custom region with fewer instances than this. */ +export const MIN_CLUSTER_INSTANCES = 2; + +/** + * One shape for a catalog tier and an organization's custom region, keyed by the id central-manager + * stores on the plan. `instanceCount` and `blocksPerUnit` are per quantity unit; a catalog tier is + * always one unit. + */ +export type ResolvedRegion = + & { + id: string; + name: string; + instanceCount: number; + blocksPerUnit: number; + active: boolean; + } + & ( + | { kind: 'catalog'; latencyDescription: string; source: SchemaRegion } + | { kind: 'organization'; shape: string[]; fallbackGroup: string | null; source: OrganizationRegion } + ); + +export type RegionLookup = ReadonlyMap; + +/** The datacenters one unit of an organization region occupies on the organization's provider. */ +export function organizationRegionShape( + region: OrganizationRegion, + provider: keyof SchemaCloudInstanceTypes | undefined, +): string[] { + return region.placement?.[provider ?? 'gcp'] ?? []; +} + +export function buildRegionLookup( + catalog: readonly SchemaRegion[] | undefined, + organizationRegions: readonly OrganizationRegion[] | undefined, + provider: keyof SchemaCloudInstanceTypes | undefined, +): RegionLookup { + const lookup = new Map(); + for (const region of catalog ?? []) { + lookup.set(region.id, { + kind: 'catalog', + id: region.id, + name: region.region, + latencyDescription: region.latencyDescription, + instanceCount: region.instanceCount, + blocksPerUnit: region.purchasedBlockMultiplier ?? 1, + active: (region as { active?: boolean }).active !== false, + source: region, + }); + } + for (const region of organizationRegions ?? []) { + const shape = organizationRegionShape(region, provider); + lookup.set(region.id, { + kind: 'organization', + id: region.id, + name: region.name, + shape, + fallbackGroup: region.fallbackGroup ?? null, + instanceCount: shape.length, + blocksPerUnit: region.blocksPerUnit ?? 1, + active: region.active !== false, + source: region, + }); + } + return lookup; +} + +/** + * What the resources panel and the price line scale by: a quantity-expanded view of the region + * that reads like a catalog row. + */ +export function regionAtQuantity(region: ResolvedRegion, quantity: number | undefined): SchemaRegion { + const units = quantity ?? 1; + return { + id: region.id, + region: region.name, + latencyDescription: region.kind === 'catalog' ? region.latencyDescription : describeShape(region.shape), + instanceCount: region.instanceCount * units, + purchasedBlockMultiplier: region.blocksPerUnit * units, + }; +} + +/** "fr-par ×2 · it-mil" — each datacenter once, with its count when repeated. */ +export function describeShape(shape: readonly string[]): string { + const counts = new Map(); + for (const datacenter of shape) { + counts.set(datacenter, (counts.get(datacenter) ?? 0) + 1); + } + return [...counts].map(([datacenter, count]) => (count > 1 ? `${datacenter} ×${count}` : datacenter)).join(' · '); +} + +/** + * The key central-manager groups plans under: a catalog family shares a name across its tiers, an + * organization region is its own cohort. Two entries with the same key are a duplicate region. + */ +export function regionCohortKey(region: ResolvedRegion): string { + return region.kind === 'organization' ? region.id : region.name; +} diff --git a/src/integrations/api/api.patch.d.ts b/src/integrations/api/api.patch.d.ts index 0632bc136..55f4dac02 100644 --- a/src/integrations/api/api.patch.d.ts +++ b/src/integrations/api/api.patch.d.ts @@ -1,8 +1,10 @@ import { SchemaCluster, SchemaClusterUpsert, + SchemaClusterUpsertRegionPlan, SchemaHdbInstance, SchemaOrganization, + SchemaRegionPlan, SchemaRole, SchemaUser, } from './api.gen'; @@ -154,6 +156,52 @@ export interface AdminRegionPayload { organizationIds?: string[] | null; } +/** + * An organization-owned custom region (central-manager `OrganizationRegion`, ids `oreg-…`): the + * datacenters one unit occupies per provider, an optional fallback pool and the purchased blocks + * minted per unit. Clusters deploy it with `RegionPlan.quantity` units. Internal-only: staff with + * `region:write` create and edit rows; organization members may read them. + */ +export interface OrganizationRegion { + id: string; + organizationId: string; + name: string; + placement: { linode?: string[]; gcp?: string[] }; + /** A Location region tag to fall back to; null = forced placement (never spills elsewhere). */ + fallbackGroup?: string | null; + blocksPerUnit: number; + active?: boolean; + createdByUserId?: string; + updatedByUserId?: string; + createdAt?: string; + updatedAt?: string; + /** GET /OrganizationRegion/:id only: live clusters whose plans deploy this region. */ + clusters?: Array<{ id: string; name: string; status?: string; quantity: number }>; +} + +/** Payload for POST /OrganizationRegion/ (create). */ +export interface OrganizationRegionPayload { + organizationId: string; + name: string; + placement: { linode?: string[]; gcp?: string[] }; + fallbackGroup?: string | null; + blocksPerUnit: number; + active?: boolean; +} + +/** Payload for PATCH /OrganizationRegion/:id. Only `active` may change while a live cluster references the row. */ +export type OrganizationRegionPatch = Partial>; + +/** A cluster's region plan entry; `quantity` (default 1) is only meaningful for an `oreg-` region. */ +export interface RegionPlan extends SchemaRegionPlan { + quantity?: number; +} + +/** A submitted region plan entry; `quantity` is accepted only for an `oreg-` region. */ +export interface ClusterUpsertRegionPlan extends SchemaClusterUpsertRegionPlan { + quantity?: number; +} + /** * A deployment location as returned by GET /Location. `location` is the value stored in a region's * preferred-location arrays; `locationName` is the display label; `cloudProvider` splits the Linode @@ -325,11 +373,12 @@ export interface Instance extends SchemaHdbInstance { safeMode?: boolean; } -export interface Cluster extends Omit { +export interface Cluster extends Omit { // TODO: Can we return enums from the server to make this easier? status?: string | 'PROVISIONING' | 'UPDATING' | 'RUNNING' | 'TERMINATED' | 'FAILED'; // Use the patched Instance (adds status + safeMode) rather than the raw generated shape. instances?: Instance[]; + plans?: RegionPlan[]; } export interface ClusterSyncSummary { @@ -338,7 +387,8 @@ export interface ClusterSyncSummary { progress?: number; } -export interface ClusterUpsert extends SchemaClusterUpsert { +export interface ClusterUpsert extends Omit { + regionPlans: ClusterUpsertRegionPlan[]; skipGtmWait?: boolean; version?: string; } From f9d70709610db23dd5965127e0171161c1e5bbf6 Mon Sep 17 00:00:00 2001 From: David C Date: Fri, 2 Oct 2026 16:56:04 -0400 Subject: [PATCH 02/24] feat(clusters): key region plans by id and let staff deploy custom regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cluster form stored each region plan as a region name plus latency description and resolved the id at submit; a custom region has neither, so the form entry becomes `{ regionId, quantity? }`. An id-keyed lookup feeds validation, price, the resources panel, display and submit; the region-name and latency selects are now a view over it, and a selected custom region swaps the latency select for a units field (datacenters per unit × units = instances). Staff with the cluster permission see the organization's custom regions in the picker and can define one inline; a member keeps a custom region staff already placed but cannot add or swap one. Edit defaults resolve every stored plan by id and refuse to open the form when one cannot be resolved, since saving without it would remove that region. Saved drafts keyed the old way migrate through the catalog. Switching deployment carries a catalog choice over by name and latency tier where the new catalog has a match. The cluster list resolves custom-region names for its region filter. Refs #1778 Co-Authored-By: Claude Fable 5.1 --- src/features/clusters/ClustersList.test.tsx | 13 +- src/features/clusters/ClustersList.tsx | 15 +- .../clusters/upsert/ClusterDetails.tsx | 10 + src/features/clusters/upsert/ClusterForm.tsx | 176 ++++++++++---- .../clusters/upsert/ClusterRegions.tsx | 90 +++++-- .../upsert/components/RegionFormInputs.tsx | 229 +++++++++++++----- src/features/clusters/upsert/index.tsx | 111 ++++++--- ...aultDeploymentPerformanceAndRegionPlans.ts | 7 +- .../upsert/lib/regionPlanDefaults.test.ts | 73 ++++++ .../clusters/upsert/lib/regionPlanDefaults.ts | 66 +++++ .../upsert/upsertClusterSchema.test.ts | 25 +- .../clusters/upsert/upsertClusterSchema.ts | 19 +- 12 files changed, 643 insertions(+), 191 deletions(-) create mode 100644 src/features/clusters/upsert/lib/regionPlanDefaults.test.ts create mode 100644 src/features/clusters/upsert/lib/regionPlanDefaults.ts diff --git a/src/features/clusters/ClustersList.test.tsx b/src/features/clusters/ClustersList.test.tsx index 6b20eaa08..7f32ef8c9 100644 --- a/src/features/clusters/ClustersList.test.tsx +++ b/src/features/clusters/ClustersList.test.tsx @@ -10,11 +10,14 @@ const state = vi.hoisted(() => ({ create: false, saved: null as unknown, regions: [] as { id: string; region: string }[], + organizationRegions: [] as { id: string; name: string }[], })); vi.mock( '@tanstack/react-query', () => ({ - useQuery: () => ({ data: state.regions }), + useQuery: ({ queryKey }: { queryKey?: unknown[] }) => ({ + data: queryKey?.[0] === 'organizationRegions' ? state.organizationRegions : state.regions, + }), useSuspenseQuery: () => ({ data: { clusters: state.clusters } }), queryOptions: (value: unknown) => value, }), @@ -43,6 +46,7 @@ afterEach(() => { state.create = false; state.saved = null; state.regions = []; + state.organizationRegions = []; }); const cluster = (id: string, status: string, region: string): Cluster => ({ id, @@ -80,10 +84,15 @@ describe('ClustersList', () => { }); it('resolves id-only plans from the shared catalog and preserves sorting when clearing filters', () => { - state.clusters = [{ ...cluster('Production', 'RUNNING', ''), plans: [{ planId: 'shared', regionId: 'reg-east' }] }]; + state.clusters = [ + { ...cluster('Production', 'RUNNING', ''), plans: [{ planId: 'shared', regionId: 'reg-east' }] }, + { ...cluster('Edge', 'RUNNING', ''), plans: [{ planId: 'shared', regionId: 'oreg-1', quantity: 2 }] }, + ]; state.regions = [{ id: 'reg-east', region: 'US East' }]; + state.organizationRegions = [{ id: 'oreg-1', name: 'EU edge' }]; render(); expect(screen.getByRole('option', { name: 'US East' })).toBeTruthy(); + expect(screen.getByRole('option', { name: 'EU edge' })).toBeTruthy(); fireEvent.change(screen.getByLabelText('Sort clusters'), { target: { value: 'name' } }); fireEvent.change(screen.getByLabelText('Search clusters'), { target: { value: 'East' } }); expect(screen.getByRole('article').textContent).toBe('Production'); diff --git a/src/features/clusters/ClustersList.tsx b/src/features/clusters/ClustersList.tsx index d54f9d117..df2049f3d 100644 --- a/src/features/clusters/ClustersList.tsx +++ b/src/features/clusters/ClustersList.tsx @@ -2,6 +2,7 @@ import { SubNavMenu } from '@/components/SubNavMenu'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { isLocalStudio } from '@/config/constants'; +import { getOrganizationRegionsQueryOptions } from '@/features/admin/organizationRegions/queries/getOrganizationRegions'; import { ClusterCard } from '@/features/clusters/components/ClusterCard'; import { buildClusterList, @@ -46,7 +47,19 @@ export function ClustersList() { enabled: !isLocalStudio, staleTime: 60_000, }); - const regionNames = useMemo(() => new Map(regionCatalog?.map(region => [region.id, region.region])), [regionCatalog]); + const { data: organizationRegions } = useQuery({ + ...getOrganizationRegionsQueryOptions(organizationId), + enabled: !isLocalStudio, + staleTime: 60_000, + }); + const regionNames = useMemo( + () => + new Map([ + ...(regionCatalog?.map(region => [region.id, region.region] as const) ?? []), + ...(organizationRegions?.map(region => [region.id, region.name] as const) ?? []), + ]), + [organizationRegions, regionCatalog], + ); const model = useMemo( () => buildClusterList(orgInfo?.clusters ?? [], regionNames, orgInfo?.clusterSyncSummaries), [orgInfo?.clusters, regionNames, orgInfo?.clusterSyncSummaries], diff --git a/src/features/clusters/upsert/ClusterDetails.tsx b/src/features/clusters/upsert/ClusterDetails.tsx index 41d69337e..7f331d809 100644 --- a/src/features/clusters/upsert/ClusterDetails.tsx +++ b/src/features/clusters/upsert/ClusterDetails.tsx @@ -19,6 +19,7 @@ import { ClusterInstances } from './components/ClusterInstances'; import { calculatePremiumOnlyRegions } from './lib/calculatePremiumOnlyRegions'; import { calculateUsageScale } from './lib/calculateUsageScale'; import { PartialUpgrade } from './lib/detectPartialUpgrade'; +import { RegionLookup } from './lib/regionLookup'; import { UpsertClusterSchemaType } from './upsertClusterSchema'; interface ClusterDetailsProps { @@ -34,7 +35,10 @@ interface ClusterDetailsProps { mode: 'version' | undefined; partialUpgrade: PartialUpgrade | null; regionLocations: SchemaRegion[] | undefined; + regionLookup: RegionLookup; regionNameToLatencyToRegion: Record>; + organizationId: string; + canUseCustomRegions: boolean; selectedDeployment: string; selectedPerformance: string; selectedPlan: SchemaPlan | undefined; @@ -55,7 +59,10 @@ export function ClusterDetails({ mode, partialUpgrade, regionLocations, + regionLookup, regionNameToLatencyToRegion, + organizationId, + canUseCustomRegions, selectedDeployment, selectedPerformance, selectedPlan, @@ -245,7 +252,10 @@ export function ClusterDetails({ buildRegionLookup(regionLocationsColocated, organizationRegions, cloudProvider), + [cloudProvider, organizationRegions, regionLocationsColocated], + ); + const dedicatedRegionLookup = useMemo( + () => buildRegionLookup(regionLocationsDedicated, organizationRegions, cloudProvider), + [cloudProvider, organizationRegions, regionLocationsDedicated], + ); + // A member may keep a custom region staff already placed on this cluster, but not add one. + const preexistingOrganizationRegionIds = useMemo( + () => new Set(defaultValues.regionPlans.map(entry => entry.regionId).filter(isOrganizationRegionId)), + [defaultValues], + ); const refineZod = useCallback((data: UpsertClusterSchemaType, ctx: z.RefinementCtx) => { const names = new Set(); @@ -126,53 +145,91 @@ export function ClusterForm({ message: 'Only one free cluster is allowed per organization.', }); } - const regionNameToLatencyToRegion = selectedPlan?.deploymentDescription !== 'Dedicated' - ? colocatedRegionNameToLatencyToRegion - : dedicatedRegionNameToLatencyToRegion; - + const regionLookup = selectedPlan?.deploymentDescription !== 'Dedicated' + ? colocatedRegionLookup + : dedicatedRegionLookup; + let totalInstances = 0; + let firstOrganizationRegionIndex = -1; for (let i = 0; i < data.regionPlans.length; i++) { const regionPlan = data.regionPlans[i]; - const region = regionNameToLatencyToRegion[regionPlan.regionName]?.[regionPlan.latencyDescription]; - if (!names.has(regionPlan.regionName)) { - names.add(regionPlan.regionName); + const region = regionLookup.get(regionPlan.regionId); + if (!region) { + if (regionPlan.regionId) { + ctx.addIssue({ + code: 'custom', + path: [`regionPlans.${i}.regionId`], + message: 'This region is no longer available.', + }); + } + continue; + } + const cohort = regionCohortKey(region); + if (!names.has(cohort)) { + names.add(cohort); } else { ctx.addIssue({ code: 'custom', - path: [`regionPlans.${i}.regionName`], + path: [`regionPlans.${i}.regionId`], message: 'You can only select a region once!', }); } - if (selectedPlan?.allowedRegionIds?.length && region?.id) { + if (region.kind === 'organization') { + if (firstOrganizationRegionIndex < 0) { + firstOrganizationRegionIndex = i; + } + if (!canUseCustomRegions && !preexistingOrganizationRegionIds.has(region.id)) { + ctx.addIssue({ + code: 'custom', + path: [`regionPlans.${i}.regionId`], + message: 'Custom regions can only be placed by Harper staff.', + }); + } + if (!regionPlan.quantity) { + ctx.addIssue({ + code: 'custom', + path: [`regionPlans.${i}.quantity`], + message: 'Please choose how many units to deploy.', + }); + } + totalInstances += region.instanceCount * (regionPlan.quantity ?? 1); + // Plan region restrictions describe the catalog; central-manager decides custom regions. + continue; + } + totalInstances += region.instanceCount; + if (selectedPlan?.allowedRegionIds?.length) { if (!selectedPlan.allowedRegionIds.includes(region.id)) { const prefixMatches = stringsShareAPrefix(selectedPlan.allowedRegionIds, region.id); - if (!prefixMatches) { - ctx.addIssue({ - code: 'custom', - path: [`regionPlans.${i}.regionName`], - message: `This region is not available with the selected performance tier!`, - }); - } else { - ctx.addIssue({ - code: 'custom', - path: [`regionPlans.${i}.latencyDescription`], - message: `This latency is not available with the selected performance tier!`, - }); - } + ctx.addIssue({ + code: 'custom', + path: [`regionPlans.${i}.regionId`], + message: prefixMatches + ? `This latency is not available with the selected performance tier!` + : `This region is not available with the selected performance tier!`, + }); } else if (i >= 1) { ctx.addIssue({ code: 'custom', - path: [`regionPlans.${i}.regionName`], + path: [`regionPlans.${i}.regionId`], message: `You can only select one region with this performance tier!`, }); } } } + if (firstOrganizationRegionIndex >= 0 && totalInstances < MIN_CLUSTER_INSTANCES) { + ctx.addIssue({ + code: 'custom', + path: [`regionPlans.${firstOrganizationRegionIndex}.quantity`], + message: `A cluster needs at least ${MIN_CLUSTER_INSTANCES} instances in total.`, + }); + } } }, [ alreadyUsingFree, - colocatedRegionNameToLatencyToRegion, - dedicatedRegionNameToLatencyToRegion, + canUseCustomRegions, + colocatedRegionLookup, + dedicatedRegionLookup, deploymentToPerformanceToPlan, + preexistingOrganizationRegionIds, ]); const form = useForm({ @@ -202,6 +259,9 @@ export function ClusterForm({ const regionLocations = selectedDeployment !== 'Dedicated' ? regionLocationsColocated : regionLocationsDedicated; + const regionLookup = selectedDeployment !== 'Dedicated' + ? colocatedRegionLookup + : dedicatedRegionLookup; useEffect(function syncInstancesAndRegionsWithSelfManagedSelection() { const values = form.getValues(); @@ -259,42 +319,52 @@ export function ClusterForm({ useEffect(function autoSelectRegionBasedOnAllowedRegionIds() { const allowedRegionIds = selectedPlan?.allowedRegionIds; if (allowedRegionIds?.length && selectedRegionPlans?.length === 1) { - const firstRegion = selectedRegionPlans[0]; - const firstSelectedRegion = regionNameToLatencyToRegion?.[firstRegion.regionName] - ?.[firstRegion.latencyDescription]; - if (!allowedRegionIds.includes(firstSelectedRegion?.id)) { + const firstSelectedRegion = regionLookup.get(selectedRegionPlans[0].regionId); + if (firstSelectedRegion?.kind !== 'organization' && !allowedRegionIds.includes(firstSelectedRegion?.id ?? '')) { const possibleRegions = regionLocations?.filter(r => allowedRegionIds.includes(r.id)); const regionToSelect = possibleRegions?.find(r => r.region === 'US') || possibleRegions?.[0]; if (regionToSelect) { - form.setValue('regionPlans.0.regionName', regionToSelect.region); - form.setValue('regionPlans.0.latencyDescription', regionToSelect.latencyDescription); + form.setValue('regionPlans.0.regionId', regionToSelect.id); void form.trigger(); } } } - }, [selectedPlan, selectedRegionPlans, form, regionNameToLatencyToRegion, regionLocations]); + }, [selectedPlan, selectedRegionPlans, form, regionLookup, regionLocations]); useEffect(function syncRegionSelectionsWithPossibleRegions() { const isSelfManaged = selectedDeployment === 'Self-Hosted'; - if (!isSelfManaged && Object.keys(regionNameToLatencyToRegion).length && selectedRegionPlans.length) { - for (let i = 0; i < selectedRegionPlans.length; i++) { - const regionPlan = selectedRegionPlans[i]; - if (!regionNameToLatencyToRegion[regionPlan.regionName]) { - form.setValue(`regionPlans.${i}.regionName`, ''); - } + if (isSelfManaged || !regionLookup.size || !selectedRegionPlans.length) { + return; + } + for (let i = 0; i < selectedRegionPlans.length; i++) { + const { regionId } = selectedRegionPlans[i]; + // A custom region is deployment-agnostic; one the lookup lacks is pending a refetch (or gone), + // which validation reports without discarding the selection. + if (!regionId || regionLookup.has(regionId) || isOrganizationRegionId(regionId)) { + continue; } + // Switching deployment swaps the catalog: carry the choice over by name and latency tier + // where the new catalog has a match, otherwise clear it. + const previous = colocatedRegionLookup.get(regionId) ?? dedicatedRegionLookup.get(regionId); + const previousTier = previous?.kind === 'catalog' ? previous.latencyDescription.split(' ')[0].toLowerCase() : ''; + const replacement = previous + && (regionLocations?.find(r => + r.region === previous.name && r.latencyDescription.split(' ')[0].toLowerCase() === previousTier + ) + ?? regionLocations?.find(r => r.region === previous.name)); + form.setValue(`regionPlans.${i}.regionId`, replacement?.id ?? ''); } - }, [form, regionNameToLatencyToRegion, selectedDeployment, selectedRegionPlans]); + }, [colocatedRegionLookup, dedicatedRegionLookup, form, regionLocations, regionLookup, selectedDeployment, selectedRegionPlans]); const totalPrice = !selectedPlan?.priceUsd ? 0 : selectedDeployment === 'Self-Hosted' ? selectedInstances.length * selectedPlan.priceUsd - : selectedRegionPlans.reduce((total, region) => { - const regionPlan = regionNameToLatencyToRegion?.[region.regionName!]?.[region.latencyDescription!]; - return total + (!regionPlan + : selectedRegionPlans.reduce((total, entry) => { + const region = regionLookup.get(entry.regionId); + return total + (!region ? 0 - : selectedPlan.priceUsd * regionPlan.instanceCount / 2); + : selectedPlan.priceUsd * region.instanceCount * (entry.quantity ?? 1) / 2); }, 0); const expirationMonths = selectedPlan?.planLimits?.expirationMonths; @@ -374,7 +444,7 @@ export function ClusterForm({ const executeChangesToCluster = useCallback(async () => { const formData = form.getValues(); - const plans: SchemaRegionPlan[] = []; + const plans: ClusterUpsertRegionPlan[] = []; const plan = deploymentToPerformanceToPlan[formData.deploymentDescription][formData.performanceDescription]; const isSelfManaged = formData.deploymentDescription === 'Self-Hosted'; @@ -390,11 +460,18 @@ export function ClusterForm({ } } else { for (const regionPlan of formData.regionPlans) { - const region = regionNameToLatencyToRegion[regionPlan.regionName][regionPlan.latencyDescription]; + // Re-resolve at submit: the catalog or the custom-region list may have changed since validation. + const region = regionLookup.get(regionPlan.regionId); + if (!region) { + toast.error('A selected region is no longer available. Please review your regions.'); + void form.trigger(); + return; + } plans.push({ autoRenew: true, planId: plan.id, regionId: region.id, + ...(region.kind === 'organization' ? { quantity: regionPlan.quantity ?? 1 } : {}), }); } } @@ -450,7 +527,7 @@ export function ClusterForm({ onClusterSavedCallback, onStartSaving, organizationId, - regionNameToLatencyToRegion, + regionLookup, setSavedClusterState, submitEditClusterData, submitNewClusterData, @@ -552,7 +629,10 @@ export function ClusterForm({ mode={mode} partialUpgrade={partialUpgrade} regionLocations={regionLocations} + regionLookup={regionLookup} regionNameToLatencyToRegion={regionNameToLatencyToRegion} + organizationId={organizationId} + canUseCustomRegions={canUseCustomRegions} selectedDeployment={selectedDeployment} selectedPerformance={selectedPerformance} selectedPlan={selectedPlan} diff --git a/src/features/clusters/upsert/ClusterRegions.tsx b/src/features/clusters/upsert/ClusterRegions.tsx index 05281179d..7d9c5b992 100644 --- a/src/features/clusters/upsert/ClusterRegions.tsx +++ b/src/features/clusters/upsert/ClusterRegions.tsx @@ -1,18 +1,22 @@ import { ContactUs } from '@/components/ContactUs'; import { ErrorComponent } from '@/components/ErrorComponent'; import { Button } from '@/components/ui/button'; +import { OrganizationRegionFormModal } from '@/features/admin/organizationRegions/components/OrganizationRegionFormModal'; import { SchemaCloudInstanceTypes, SchemaPlan, SchemaRegion } from '@/integrations/api/api.gen'; -import { PlusIcon } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { MapPinnedIcon, PlusIcon } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; import { useFieldArray, UseFormReturn } from 'react-hook-form'; import { RegionFormInputs } from './components/RegionFormInputs'; import { PremiumOnlyRegions } from './lib/calculatePremiumOnlyRegions'; import { UsageScale } from './lib/calculateUsageScale'; +import { regionCohortKey, RegionLookup } from './lib/regionLookup'; import { UpsertClusterSchemaType } from './upsertClusterSchema'; interface ClusterRegionsProps { form: UseFormReturn; regionLocations: SchemaRegion[] | undefined; + regionLookup: RegionLookup; regionNameToLatencyToRegion: Record>; premiumOnlyRegions: PremiumOnlyRegions; usageScale: UsageScale; @@ -20,11 +24,15 @@ interface ClusterRegionsProps { totalPrice: number | undefined; isEnterprise: boolean; cloudProvider: keyof SchemaCloudInstanceTypes | undefined; + organizationId: string; + /** Staff may pick an organization's custom regions and set their quantity. */ + canUseCustomRegions: boolean; } export function ClusterRegions({ form, regionLocations, + regionLookup, regionNameToLatencyToRegion, premiumOnlyRegions, usageScale, @@ -32,6 +40,8 @@ export function ClusterRegions({ totalPrice, isEnterprise, cloudProvider, + organizationId, + canUseCustomRegions, }: ClusterRegionsProps) { const selectedRegionPlans = form.watch('regionPlans'); @@ -41,26 +51,41 @@ export function ClusterRegions({ }); const nextAvailableRegionToAdd = useMemo(() => { - const selectedRegionNames = selectedRegionPlans.map(region => - regionNameToLatencyToRegion?.[region.regionName!]?.[region.latencyDescription!]?.region - ); if (!totalPrice) { // Free plans can only add a single region. return null; } - return regionLocations?.find(r => !selectedRegionNames.includes(r.region)); - }, [regionLocations, regionNameToLatencyToRegion, selectedRegionPlans, totalPrice]); + const selectedCohorts = new Set( + selectedRegionPlans.map(entry => { + const region = regionLookup.get(entry.regionId); + return region ? regionCohortKey(region) : null; + }), + ); + return regionLocations?.find(r => !selectedCohorts.has(r.region)); + }, [regionLocations, regionLookup, selectedRegionPlans, totalPrice]); const onAddARegionClick = useCallback(() => { if (nextAvailableRegionToAdd) { - regionPlansFieldArray.append({ - regionName: nextAvailableRegionToAdd.region, - latencyDescription: nextAvailableRegionToAdd.latencyDescription, - }); + regionPlansFieldArray.append({ regionId: nextAvailableRegionToAdd.id }); void form.trigger(); } }, [form, nextAvailableRegionToAdd, regionPlansFieldArray]); + const [definingCustomRegion, setDefiningCustomRegion] = useState(false); + // The new row lands on the form before its refetch reaches the lookup; validation reports it + // as unavailable until then rather than the row being dropped. + const onCustomRegionSaved = useCallback((region: OrganizationRegion) => { + const values = form.getValues('regionPlans'); + const blankIndex = values.findIndex(entry => !entry.regionId); + if (blankIndex >= 0) { + form.setValue(`regionPlans.${blankIndex}.regionId`, region.id, { shouldDirty: true }); + form.setValue(`regionPlans.${blankIndex}.quantity`, 1, { shouldDirty: true }); + } else { + regionPlansFieldArray.append({ regionId: region.id, quantity: 1 }); + } + void form.trigger(); + }, [form, regionPlansFieldArray]); + if (!regionLocations?.length) { return (
@@ -90,27 +115,50 @@ export function ClusterRegions({ form={form} index={index} key={field.id} + regionLookup={regionLookup} regionNameToLatencyToRegion={regionNameToLatencyToRegion} premiumOnlyRegions={premiumOnlyRegions} usageScale={usageScale} selectedPlan={selectedPlan} isEnterprise={isEnterprise} cloudProvider={cloudProvider} + organizationId={organizationId} + canUseCustomRegions={canUseCustomRegions} /> ))} - {nextAvailableRegionToAdd && ( -
- + {(nextAvailableRegionToAdd || canUseCustomRegions) && ( +
+ {nextAvailableRegionToAdd && ( + + )} + {canUseCustomRegions && ( + + )}
)} + {canUseCustomRegions && ( + + )} ); } diff --git a/src/features/clusters/upsert/components/RegionFormInputs.tsx b/src/features/clusters/upsert/components/RegionFormInputs.tsx index dfc68a1f6..984fd44e8 100644 --- a/src/features/clusters/upsert/components/RegionFormInputs.tsx +++ b/src/features/clusters/upsert/components/RegionFormInputs.tsx @@ -5,15 +5,24 @@ import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; import { UpsertClusterSchemaType } from '@/features/clusters/upsert/upsertClusterSchema'; import { SchemaCloudInstanceTypes, SchemaPlan, SchemaRegion } from '@/integrations/api/api.gen'; import { sortByNumberPrefix } from '@/lib/arrays/sort/byNumberPrefix'; +import { pluralize } from '@/lib/pluralize'; import { MapPinIcon, TrashIcon } from 'lucide-react'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { Control, UseFieldArrayReturn, UseFormReturn } from 'react-hook-form'; import { PremiumOnlyRegions } from '../lib/calculatePremiumOnlyRegions'; import { UsageScale } from '../lib/calculateUsageScale'; +import { + describeShape, + isOrganizationRegionId, + MAX_REGION_PLAN_QUANTITY, + regionAtQuantity, + RegionLookup, +} from '../lib/regionLookup'; import { ResourcesPerInstance } from './ResourcesPerInstance'; type RegionFormInputsProps = { @@ -21,36 +30,67 @@ type RegionFormInputsProps = { fieldArray: UseFieldArrayReturn; form: UseFormReturn; index: number; + regionLookup: RegionLookup; regionNameToLatencyToRegion: Record>; premiumOnlyRegions: PremiumOnlyRegions; usageScale: UsageScale; selectedPlan: SchemaPlan | undefined; isEnterprise: boolean; cloudProvider: keyof SchemaCloudInstanceTypes | undefined; + organizationId: string; + canUseCustomRegions: boolean; }; +/** The latency tier in `options` closest to `preferred` ("Low …" → "Low …"), else the first. */ +function pickLatencyDescription(options: readonly string[], preferred: string | undefined): string | undefined { + const tier = preferred?.split(' ')[0].toLowerCase(); + return options.find(description => !tier ? true : description.split(' ')[0].toLowerCase() === tier) || options[0]; +} + +/** + * One region plan row. The form stores a region id; the region-name and latency selects are a view + * over the catalog for that id, and a custom region swaps the latency select for a quantity field. + */ export function RegionFormInputs({ control, fieldArray, form, index, + regionLookup, regionNameToLatencyToRegion, premiumOnlyRegions, usageScale, selectedPlan, isEnterprise, cloudProvider, + canUseCustomRegions, }: RegionFormInputsProps) { const availableRegionNames = useMemo(() => Object.keys(regionNameToLatencyToRegion).sort(), [ regionNameToLatencyToRegion, ]); const isDedicated = form.watch('deploymentDescription')?.startsWith('Dedicated'); - const selectedRegionName = form.watch(`regionPlans.${index}.regionName`); - const selectedLatencyDescription = form.watch(`regionPlans.${index}.latencyDescription`); + const entryRegionId = form.watch(`regionPlans.${index}.regionId`); + const entryQuantity = form.watch(`regionPlans.${index}.quantity`); + const resolved = regionLookup.get(entryRegionId); + const catalogSelection = resolved?.kind === 'catalog' ? resolved : undefined; + const organizationSelection = resolved?.kind === 'organization' ? resolved : undefined; + const selectedRegionName = catalogSelection?.name ?? ''; + const selectedLatencyDescription = catalogSelection?.latencyDescription ?? ''; const availableLatencyDescriptions = useMemo( () => Object.keys(regionNameToLatencyToRegion[selectedRegionName] || {}).sort(sortByNumberPrefix).reverse(), [regionNameToLatencyToRegion, selectedRegionName], ); + // Inactive custom regions stay listed only while this row already deploys one. + const organizationRegions = useMemo( + () => + [...regionLookup.values()].filter(region => + region.kind === 'organization' && (region.active || region.id === entryRegionId) + ), + [entryRegionId, regionLookup], + ); + const showCustomGroup = canUseCustomRegions ? organizationRegions.length > 0 : !!organizationSelection; + // A member keeps a custom region staff placed, but cannot swap it for anything else. + const lockedToOrganizationRegion = !!organizationSelection && !canUseCustomRegions; const allowedRegionIds = selectedPlan?.allowedRegionIds; const isRegionAllowedByPlan = useCallback( @@ -72,19 +112,28 @@ export function RegionFormInputs({ [allowedRegionIds, regionNameToLatencyToRegion, selectedRegionName], ); - useEffect(function autoPickLatencyDescription() { - if ( - selectedRegionName && availableLatencyDescriptions?.length - && !availableLatencyDescriptions?.includes(selectedLatencyDescription) - ) { - const oldValue = selectedLatencyDescription?.split(' ')[0].toLowerCase(); - const newValue = availableLatencyDescriptions.find(description => - !oldValue ? true : description.split(' ')[0].toLowerCase() === oldValue - ) || availableLatencyDescriptions[0]; - form.setValue(`regionPlans.${index}.latencyDescription`, newValue); - void form.trigger(); + const setEntry = useCallback((regionId: string, quantity: number | undefined) => { + form.setValue(`regionPlans.${index}.regionId`, regionId, { shouldDirty: true }); + form.setValue(`regionPlans.${index}.quantity`, quantity, { shouldDirty: true }); + void form.trigger(); + }, [form, index]); + + const onRegionValueChange = useCallback((value: string) => { + if (isOrganizationRegionId(value)) { + setEntry(value, entryQuantity ?? 1); + return; + } + const latencyOptions = Object.keys(regionNameToLatencyToRegion[value] || {}).sort(sortByNumberPrefix).reverse(); + const latency = pickLatencyDescription(latencyOptions, selectedLatencyDescription); + setEntry((latency && regionNameToLatencyToRegion[value]?.[latency]?.id) || '', undefined); + }, [entryQuantity, regionNameToLatencyToRegion, selectedLatencyDescription, setEntry]); + + const onLatencyValueChange = useCallback((value: string) => { + const region = regionNameToLatencyToRegion[selectedRegionName]?.[value]; + if (region) { + setEntry(region.id, undefined); } - }, [availableLatencyDescriptions, form, index, selectedLatencyDescription, selectedRegionName]); + }, [regionNameToLatencyToRegion, selectedRegionName, setEntry]); const onRemoveClicked = useCallback(() => { fieldArray?.remove(index); @@ -95,8 +144,8 @@ export function RegionFormInputs({
( + name={`regionPlans.${index}.regionId`} + render={() => ( @@ -104,17 +153,16 @@ export function RegionFormInputs({ @@ -132,51 +194,86 @@ export function RegionFormInputs({ )} /> - ( - + {organizationSelection + ? ( + <> +
+ Datacenters per unit +

+ {organizationSelection.shape.length + ? describeShape(organizationSelection.shape) + : 'No datacenters on this provider'} +

+
+ ( + + Units + + { + const raw = event.target.value; + field.onChange(raw === '' ? undefined : Number(raw)); + void form.trigger(); + }} + onBlur={field.onBlur} + name={field.name} + ref={field.ref} + disabled={!canUseCustomRegions} + /> + +

+ {pluralize(organizationSelection.instanceCount * (entryQuantity ?? 1), 'instance', 'instances')} +

+ +
+ )} + /> + + ) + : ( +
Estimated {isDedicated ? 'P95' : 'P90'} Latency, Distribution - - - - - + +
)} - /> - {fieldArray?.fields?.length && fieldArray?.fields?.length > 1 && ( + {fieldArray?.fields?.length && fieldArray?.fields?.length > 1 && !lockedToOrganizationRegion && (
)} diff --git a/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts b/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts index 1c004cd5b..03ba1bf7d 100644 --- a/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts +++ b/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts @@ -12,7 +12,10 @@ export async function createOrganizationRegion(payload: OrganizationRegionPayloa export async function updateOrganizationRegion( { id, changes }: { id: string; changes: OrganizationRegionPatch }, ): Promise { - const { data } = await apiClient.patch(`/OrganizationRegion/${encodeURIComponent(id)}` as '/Region/{id}', changes as never); + const { data } = await apiClient.patch( + `/OrganizationRegion/${encodeURIComponent(id)}` as '/Region/{id}', + changes as never, + ); return data as unknown as OrganizationRegion; } @@ -20,7 +23,10 @@ export async function updateOrganizationRegion( const meta = { skipGlobalErrorToast: true }; export function useCreateOrganizationRegionMutation() { - return useMutation({ mutationFn: createOrganizationRegion, meta }); + return useMutation({ + mutationFn: createOrganizationRegion, + meta, + }); } export function useUpdateOrganizationRegionMutation() { diff --git a/src/features/admin/regions/components/DatacenterCountSummary.tsx b/src/features/admin/regions/components/DatacenterCountSummary.tsx index d5f929baf..3b189e3f6 100644 --- a/src/features/admin/regions/components/DatacenterCountSummary.tsx +++ b/src/features/admin/regions/components/DatacenterCountSummary.tsx @@ -1,7 +1,9 @@ import { describeShape } from '@/features/clusters/upsert/lib/regionLookup'; // The pickers allow repeats but render one chip per entry; this is where the count shows. -export function DatacenterCountSummary({ datacenters, ariaLabel }: { datacenters: readonly string[]; ariaLabel: string }) { +export function DatacenterCountSummary( + { datacenters, ariaLabel }: { datacenters: readonly string[]; ariaLabel: string }, +) { if (datacenters.length === 0) { return null; } diff --git a/src/features/clusters/upsert/ClusterForm.tsx b/src/features/clusters/upsert/ClusterForm.tsx index 2d35787ef..af3b07c91 100644 --- a/src/features/clusters/upsert/ClusterForm.tsx +++ b/src/features/clusters/upsert/ClusterForm.tsx @@ -369,7 +369,15 @@ export function ClusterForm({ ?? regionLocations?.find(r => r.region === previous.name)); form.setValue(`regionPlans.${i}.regionId`, replacement?.id ?? ''); } - }, [colocatedRegionLookup, dedicatedRegionLookup, form, regionLocations, regionLookup, selectedDeployment, selectedRegionPlans]); + }, [ + colocatedRegionLookup, + dedicatedRegionLookup, + form, + regionLocations, + regionLookup, + selectedDeployment, + selectedRegionPlans, + ]); useEffect(function revalidateCustomRegionsWhenLookupChanges() { // A custom region defined inline only resolves once its refetch lands. @@ -495,7 +503,9 @@ export function ClusterForm({ } // Payment review submits without re-validating, and the shape can change while the user is away. if (region.kind === 'organization' && region.instanceCount === 0) { - toast.error(`${region.name} has no datacenters on the organization's cloud provider. Please review your regions.`); + toast.error( + `${region.name} has no datacenters on the organization's cloud provider. Please review your regions.`, + ); void form.trigger(); return; } @@ -666,7 +676,7 @@ export function ClusterForm({ organizationId={organizationId} canUseCustomRegions={canUseCustomRegions} canDefineCustomRegions={canDefineCustomRegions} - lockedOrganizationRegionIds={lockedOrganizationRegionIds} + lockedOrganizationRegionIds={lockedOrganizationRegionIds} selectedDeployment={selectedDeployment} selectedPerformance={selectedPerformance} selectedPlan={selectedPlan} diff --git a/src/features/clusters/upsert/ClusterRegions.tsx b/src/features/clusters/upsert/ClusterRegions.tsx index bd123a53d..574edf77b 100644 --- a/src/features/clusters/upsert/ClusterRegions.tsx +++ b/src/features/clusters/upsert/ClusterRegions.tsx @@ -7,6 +7,7 @@ import { OrganizationRegion } from '@/integrations/api/api.patch'; import { MapPinnedIcon, PlusIcon } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { useFieldArray, UseFormReturn } from 'react-hook-form'; +import { toast } from 'sonner'; import { RegionFormInputs } from './components/RegionFormInputs'; import { PremiumOnlyRegions } from './lib/calculatePremiumOnlyRegions'; import { UsageScale } from './lib/calculateUsageScale'; @@ -90,17 +91,11 @@ export function ClusterRegions({ }, [form, nextAvailableRegionToAdd, regionPlansFieldArray]); const [definingCustomRegion, setDefiningCustomRegion] = useState(false); + // Defining a region only adds it to the picker's Custom regions group: staff define as many as + // the organization needs, then choose which ones this cluster deploys. const onCustomRegionSaved = useCallback((region: OrganizationRegion) => { - const values = form.getValues('regionPlans'); - const blankIndex = values.findIndex(entry => !entry.regionId); - if (blankIndex >= 0) { - form.setValue(`regionPlans.${blankIndex}.regionId`, region.id, { shouldDirty: true }); - form.setValue(`regionPlans.${blankIndex}.quantity`, 1, { shouldDirty: true }); - } else { - regionPlansFieldArray.append({ regionId: region.id, quantity: 1 }); - } - void form.trigger(); - }, [form, regionPlansFieldArray]); + toast.success(`${region.name} is ready to pick under Custom regions`); + }, []); if (!regionLocations?.length) { return ( @@ -141,6 +136,7 @@ export function ClusterRegions({ organizationId={organizationId} canUseCustomRegions={canUseCustomRegions} lockedOrganizationRegionIds={lockedOrganizationRegionIds} + selectedRegionIds={selectedRegionPlans.map(entry => entry.regionId)} /> ))} diff --git a/src/features/clusters/upsert/components/RegionFormInputs.tsx b/src/features/clusters/upsert/components/RegionFormInputs.tsx index 9c188794d..fd6d43c51 100644 --- a/src/features/clusters/upsert/components/RegionFormInputs.tsx +++ b/src/features/clusters/upsert/components/RegionFormInputs.tsx @@ -6,7 +6,15 @@ import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { UpsertClusterSchemaType } from '@/features/clusters/upsert/upsertClusterSchema'; import { SchemaCloudInstanceTypes, SchemaPlan, SchemaRegion } from '@/integrations/api/api.gen'; import { sortByNumberPrefix } from '@/lib/arrays/sort/byNumberPrefix'; @@ -40,6 +48,8 @@ type RegionFormInputsProps = { organizationId: string; canUseCustomRegions: boolean; lockedOrganizationRegionIds: string[]; + /** Every row's region id, this row's included; a custom region already on another row cannot be picked twice. */ + selectedRegionIds: string[]; }; function pickLatencyDescription(options: readonly string[], preferred: string | undefined): string | undefined { @@ -62,6 +72,7 @@ export function RegionFormInputs({ cloudProvider, canUseCustomRegions, lockedOrganizationRegionIds, + selectedRegionIds, }: RegionFormInputsProps) { const availableRegionNames = useMemo(() => Object.keys(regionNameToLatencyToRegion).sort(), [ regionNameToLatencyToRegion, @@ -181,15 +192,23 @@ export function RegionFormInputs({ {showCustomGroup && ( Custom regions - {organizationRegions.map((region) => ( - - - {region.name} - Custom - {!region.active && Inactive} - - - ))} + {organizationRegions.map((region) => { + const onAnotherRow = region.id !== entryRegionId && selectedRegionIds.includes(region.id); + return ( + + + {region.name} + Custom + {!region.active && Inactive} + {onAnotherRow && ( + + already on this cluster — change its units + + )} + + + ); + })} )} diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx index 452a240c9..3c2aab600 100644 --- a/src/features/clusters/upsert/index.tsx +++ b/src/features/clusters/upsert/index.tsx @@ -5,11 +5,11 @@ import { SubNavMenu } from '@/components/SubNavMenu'; import { SubNavSimpleLayout } from '@/components/SubNavSimpleLayout'; import { isFailed, isTerminated } from '@/components/ui/utils/badgeStatus'; import { deletedClusterStatuses } from '@/config/clusterStatuses'; +import { getOrganizationRegionsQueryOptions } from '@/features/admin/organizationRegions/queries/getOrganizationRegions'; import { ClusterPageLayout } from '@/features/cluster/components/ClusterPageLayout'; import { getPlanTypesOptions } from '@/features/cluster/queries/getPlanTypesQuery'; import { getHarperVersionsOptions, HarperVersionsResponse } from '@/features/clusters/queries/getHarperVersionsQuery'; import { getRegionLocationsOptions } from '@/features/clusters/queries/getRegionLocationsQuery'; -import { getOrganizationRegionsQueryOptions } from '@/features/admin/organizationRegions/queries/getOrganizationRegions'; import { useStaffPermission } from '@/hooks/useAuth'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useOrganizationClusterPermissions } from '@/hooks/usePermissions'; diff --git a/src/features/clusters/upsert/lib/regionLookup.test.ts b/src/features/clusters/upsert/lib/regionLookup.test.ts index 517afd690..4cc75a775 100644 --- a/src/features/clusters/upsert/lib/regionLookup.test.ts +++ b/src/features/clusters/upsert/lib/regionLookup.test.ts @@ -1,5 +1,5 @@ -import { OrganizationRegion } from '@/integrations/api/api.patch'; import { SchemaRegion } from '@/integrations/api/api.gen'; +import { OrganizationRegion } from '@/integrations/api/api.patch'; import { describe, expect, it } from 'vitest'; import { buildRegionLookup, @@ -90,7 +90,10 @@ describe('regionAtQuantity', () => { }); it('leaves a catalog tier at one unit', () => { - expect(regionAtQuantity(lookup.get('us-2')!, undefined)).toMatchObject({ instanceCount: 4, purchasedBlockMultiplier: 2 }); + expect(regionAtQuantity(lookup.get('us-2')!, undefined)).toMatchObject({ + instanceCount: 4, + purchasedBlockMultiplier: 2, + }); }); }); diff --git a/src/features/clusters/upsert/lib/regionLookup.ts b/src/features/clusters/upsert/lib/regionLookup.ts index c33f8a745..c12372872 100644 --- a/src/features/clusters/upsert/lib/regionLookup.ts +++ b/src/features/clusters/upsert/lib/regionLookup.ts @@ -1,5 +1,5 @@ -import { OrganizationRegion } from '@/integrations/api/api.patch'; import { SchemaCloudInstanceTypes, SchemaRegion } from '@/integrations/api/api.gen'; +import { OrganizationRegion } from '@/integrations/api/api.patch'; const ORGANIZATION_REGION_ID_PREFIX = 'oreg-'; diff --git a/src/features/clusters/upsert/lib/regionPlanDefaults.test.ts b/src/features/clusters/upsert/lib/regionPlanDefaults.test.ts index c86452198..3058ab8ff 100644 --- a/src/features/clusters/upsert/lib/regionPlanDefaults.test.ts +++ b/src/features/clusters/upsert/lib/regionPlanDefaults.test.ts @@ -1,5 +1,5 @@ -import { OrganizationRegion } from '@/integrations/api/api.patch'; import { SchemaRegion } from '@/integrations/api/api.gen'; +import { OrganizationRegion } from '@/integrations/api/api.patch'; import { describe, expect, it } from 'vitest'; import { buildRegionLookup } from './regionLookup'; import { buildRegionPlanDefaults, migrateDraftRegionPlans } from './regionPlanDefaults'; @@ -37,7 +37,10 @@ describe('buildRegionPlanDefaults', () => { }); it('reports ids it cannot resolve instead of dropping them', () => { - const result = buildRegionPlanDefaults([{ regionId: 'us-1', planId: 'p' }, { regionId: 'oreg-gone', planId: 'p' }], lookup); + const result = buildRegionPlanDefaults( + [{ regionId: 'us-1', planId: 'p' }, { regionId: 'oreg-gone', planId: 'p' }], + lookup, + ); expect(result.regionPlans).toEqual([{ regionId: 'us-1' }]); expect(result.unresolvedRegionIds).toEqual(['oreg-gone']); }); @@ -52,7 +55,12 @@ describe('buildRegionPlanDefaults', () => { describe('migrateDraftRegionPlans', () => { it('keeps every id-keyed entry, resolvable or not, for validation to report', () => { - expect(migrateDraftRegionPlans([{ regionId: 'us-1' }, { regionId: 'oreg-1', quantity: 2 }, { regionId: 'gone' }], lookup)) + expect( + migrateDraftRegionPlans( + [{ regionId: 'us-1' }, { regionId: 'oreg-1', quantity: 2 }, { regionId: 'gone' }], + lookup, + ), + ) .toEqual([{ regionId: 'us-1' }, { regionId: 'oreg-1', quantity: 2 }, { regionId: 'gone' }]); }); diff --git a/src/features/clusters/upsert/lib/regionPlanDefaults.ts b/src/features/clusters/upsert/lib/regionPlanDefaults.ts index 8e45b1c90..6c104127c 100644 --- a/src/features/clusters/upsert/lib/regionPlanDefaults.ts +++ b/src/features/clusters/upsert/lib/regionPlanDefaults.ts @@ -22,7 +22,9 @@ export function buildRegionPlanDefaults( continue; } regionPlans.push( - isOrganizationRegionId(plan.regionId) ? { regionId: plan.regionId, quantity: plan.quantity ?? 1 } : { regionId: plan.regionId }, + isOrganizationRegionId(plan.regionId) + ? { regionId: plan.regionId, quantity: plan.quantity ?? 1 } + : { regionId: plan.regionId }, ); } return { regionPlans, unresolvedRegionIds }; diff --git a/src/features/clusters/upsert/upsertClusterSchema.ts b/src/features/clusters/upsert/upsertClusterSchema.ts index d2b18b3e0..017a15580 100644 --- a/src/features/clusters/upsert/upsertClusterSchema.ts +++ b/src/features/clusters/upsert/upsertClusterSchema.ts @@ -39,7 +39,9 @@ export const UpsertClusterSchema = z.object({ deploymentDescription: z.string().nonempty('Please select a deployment tier.'), performanceDescription: z.string().nonempty('Please select a performance tier.'), - regionPlans: z.array(RegionPlanEntrySchema).max(50, { error: 'A maximum of 50 regions can be selected for each cluster. ' }), + regionPlans: z.array(RegionPlanEntrySchema).max(50, { + error: 'A maximum of 50 regions can be selected for each cluster. ', + }), instances: z.array( z.object({ From 86a5f06b705542257b70fa28f5fd6e77ba702972 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 10:23:03 -0400 Subject: [PATCH 10/24] fix(admin): name the provider a custom region must list, and explain one the picker cannot place David defined two custom regions with only Linode datacenters for an organization that deploys on GCP, and the cluster form hid them. The define modal now fetches the organization, puts its provider's list first and labels it, requires at least one datacenter there, and says so in the hint. The cluster form lists such a region disabled with the reason instead of hiding it. Co-Authored-By: Claude Fable 5.1 --- .../OrganizationRegionFormModal.tsx | 30 +++++++++++++++---- .../upsert/components/RegionFormInputs.tsx | 13 ++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx index fe80481d0..b815beb98 100644 --- a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx +++ b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx @@ -29,6 +29,7 @@ import { import { DatacenterCountSummary } from '@/features/admin/regions/components/DatacenterCountSummary'; import { MultiSelect, MultiSelectOption } from '@/features/admin/regions/components/MultiSelect'; import { getLocationsQueryOptions } from '@/features/admin/regions/queries/getLocations'; +import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery'; import { OrganizationRegion } from '@/integrations/api/api.patch'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -67,6 +68,11 @@ export function OrganizationRegionFormModal( const queryClient = useQueryClient(); const { data: locations = [] } = useQuery({ ...getLocationsQueryOptions(), enabled: open }); + // The organization's channel decides which provider list places instances; the other is inert. + const { data: organization } = useQuery({ ...getOrganizationQueryOptions(organizationId), enabled: open }); + const provider: 'linode' | 'gcp' = organization?.channel === 'Akamai' ? 'linode' : 'gcp'; + const providerLabel = provider === 'linode' ? 'Linode' : 'GCP'; + const providerField = provider === 'linode' ? 'linodeDatacenters' : 'gcpDatacenters'; // The list row carries no `clusters`; the by-id read does, and it decides which fields are frozen. const { data: regionWithClusters } = useQuery({ ...getOrganizationRegionQueryOptions(region?.id), @@ -119,6 +125,13 @@ export function OrganizationRegionFormModal( // not being written, so neither a pending catalog nor an `active` toggle gets refused here. const patch = region ? toPatch(region, values) : null; const placementWritten = !patch || patch.placement !== undefined || patch.fallbackGroup !== undefined; + if (placementWritten && organization && values[providerField].length === 0) { + form.setError(providerField, { + message: + `${organization.name} deploys on ${providerLabel}; a region with no ${providerLabel} datacenters places nothing.`, + }); + return; + } if (placementWritten && locations.length && values.fallbackGroup !== NO_FALLBACK) { const outside = [...values.linodeDatacenters, ...values.gcpDatacenters].find((dc) => !regionsByDatacenter.get(dc)?.includes(values.fallbackGroup) @@ -197,8 +210,10 @@ export function OrganizationRegionFormModal( control={form.control} name="linodeDatacenters" render={({ field }) => ( - - Linode datacenters + + + Linode datacenters{provider === 'linode' && ' — this organization deploys here'} + ( - - GCP datacenters + + + GCP datacenters{provider === 'gcp' && ' — this organization deploys here'} +

- Pick a datacenter again to place another instance there. The organization's cloud provider decides which - list is used. + Pick a datacenter again to place another instance there. {organization + ? `${organization.name} deploys on ${providerLabel}, so only that list places instances; the other is kept for a provider change.` + : "The organization's cloud provider decides which list is used."}

[...regionLookup.values()].filter(region => - region.kind === 'organization' - && ((region.active && region.instanceCount > 0) || region.id === entryRegionId) + region.kind === 'organization' && (region.active || region.id === entryRegionId) ), [entryRegionId, regionLookup], ); @@ -194,12 +193,20 @@ export function RegionFormInputs({ Custom regions {organizationRegions.map((region) => { const onAnotherRow = region.id !== entryRegionId && selectedRegionIds.includes(region.id); + // Listed rather than hidden, so a region placed on the other provider is explained, not lost. + const noDatacenters = region.instanceCount === 0; return ( - + {region.name} Custom {!region.active && Inactive} + {noDatacenters && ( + + no {cloudProvider === 'linode' ? 'Linode' : 'GCP'}{' '} + datacenters — edit it under Admin › Custom regions + + )} {onAnotherRow && ( already on this cluster — change its units From 673ffbb01124e25887a5186582d9e218245ce8f3 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 10:43:33 -0400 Subject: [PATCH 11/24] fix(clusters): defining a custom region no longer submits the cluster form The define modal is portaled out of the DOM but not out of the React tree, so its submit event also reached the cluster form it opens from: the region saved and, in the same click, the cluster form submitted and moved to the payment step. The modal's submit now stops propagation. Co-Authored-By: Claude Fable 5.1 --- .../components/OrganizationRegionFormModal.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx index b815beb98..e62125042 100644 --- a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx +++ b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx @@ -191,7 +191,17 @@ export function OrganizationRegionFormModal(

)} - + { + /* The dialog is portaled out of the DOM but not out of the React tree: without stopPropagation the + submit also reaches the cluster form this modal opens from, which then submits the cluster. */ + } + { + event.stopPropagation(); + void form.handleSubmit(onSubmit)(event); + }} + > Date: Wed, 7 Oct 2026 11:17:14 -0400 Subject: [PATCH 12/24] fix(clusters): let the Units box be emptied while retyping Clearing the field wrote undefined into the form, which validates on every change, so the error showed before the new number could be typed. The box now keeps a draft while editing: the form only learns a value it can hold, and an empty box on blur falls back to the last one. Co-Authored-By: Claude Fable 5.1 --- .../upsert/components/RegionFormInputs.tsx | 56 +++++++++++++++---- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/features/clusters/upsert/components/RegionFormInputs.tsx b/src/features/clusters/upsert/components/RegionFormInputs.tsx index 309689150..98a4fc4ba 100644 --- a/src/features/clusters/upsert/components/RegionFormInputs.tsx +++ b/src/features/clusters/upsert/components/RegionFormInputs.tsx @@ -20,7 +20,7 @@ import { SchemaCloudInstanceTypes, SchemaPlan, SchemaRegion } from '@/integratio import { sortByNumberPrefix } from '@/lib/arrays/sort/byNumberPrefix'; import { pluralize } from '@/lib/pluralize'; import { MapPinIcon, TrashIcon } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; +import { Ref, useCallback, useMemo, useState } from 'react'; import { Control, UseFieldArrayReturn, UseFormReturn } from 'react-hook-form'; import { PremiumOnlyRegions } from '../lib/calculatePremiumOnlyRegions'; import { UsageScale } from '../lib/calculateUsageScale'; @@ -52,6 +52,44 @@ type RegionFormInputsProps = { selectedRegionIds: string[]; }; +// The box may sit empty while the number is being retyped: the form only learns a value the box can +// hold, and an empty box on blur falls back to the last one, so no error flashes mid-edit. +function UnitsInput({ value, onChange, onBlur, name, inputRef, disabled }: { + value: number | undefined; + onChange: (value: number) => void; + onBlur: () => void; + name: string; + inputRef: Ref; + disabled: boolean; +}) { + const [draft, setDraft] = useState(null); + return ( + { + const raw = event.target.value; + setDraft(raw); + const parsed = Number(raw); + if (raw !== '' && Number.isInteger(parsed) && parsed >= 1) { + onChange(parsed); + } + }} + onBlur={() => { + setDraft(null); + onBlur(); + }} + name={name} + ref={inputRef} + disabled={disabled} + /> + ); +} + function pickLatencyDescription(options: readonly string[], preferred: string | undefined): string | undefined { const tier = preferred?.split(' ')[0].toLowerCase(); return options.find(description => !tier ? true : description.split(' ')[0].toLowerCase() === tier) || options[0]; @@ -244,21 +282,15 @@ export function RegionFormInputs({ Units - { - const raw = event.target.value; - field.onChange(raw === '' ? undefined : Number(raw)); + { + field.onChange(value); void form.trigger(); }} onBlur={field.onBlur} name={field.name} - ref={field.ref} + inputRef={field.ref} disabled={!canUseCustomRegions} /> From ef04b29b481dac66a8788ae1e0453bb516379417 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 11:56:35 -0400 Subject: [PATCH 13/24] refactor(admin): drop the Custom regions admin page; the org tab will host it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit David: the standalone admin page is the wrong home — custom regions belong on the organization's own admin tab, which lands separately. The route, rail item and page go; the modal, queries and form schema stay for the cluster form's inline define and for that tab. Co-Authored-By: Claude Fable 5.1 --- .../admin/organizationRegions/index.tsx | 144 ------------------ 1 file changed, 144 deletions(-) delete mode 100644 src/features/admin/organizationRegions/index.tsx diff --git a/src/features/admin/organizationRegions/index.tsx b/src/features/admin/organizationRegions/index.tsx deleted file mode 100644 index d84dd521d..000000000 --- a/src/features/admin/organizationRegions/index.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { OrganizationRegionFormModal } from '@/features/admin/organizationRegions/components/OrganizationRegionFormModal'; -import { getOrganizationRegionsQueryOptions } from '@/features/admin/organizationRegions/queries/getOrganizationRegions'; -import { formatOrgLabel, getOrganizationsQueryOptions } from '@/features/admin/regions/queries/getOrganizations'; -import { describeShape } from '@/features/clusters/upsert/lib/regionLookup'; -import { useStaffPermission } from '@/hooks/useAuth'; -import { OrganizationRegion } from '@/integrations/api/api.patch'; -import { useQuery } from '@tanstack/react-query'; -import { PencilIcon, PlusIcon } from 'lucide-react'; -import { useState } from 'react'; - -function PlacementSummary({ placement }: { placement: OrganizationRegion['placement'] }) { - const lines = [ - placement?.linode?.length ? `Linode: ${describeShape(placement.linode)}` : null, - placement?.gcp?.length ? `GCP: ${describeShape(placement.gcp)}` : null, - ].filter((line): line is string => line !== null); - return ( -
- {lines.map((line) => {line})} -
- ); -} - -export function OrganizationRegionsIndex() { - const [organizationId, setOrganizationId] = useState(''); - const { data: orgResult, isLoading: orgsLoading } = useQuery(getOrganizationsQueryOptions()); - const { data: regions, isLoading, isError } = useQuery( - getOrganizationRegionsQueryOptions(organizationId || undefined), - ); - const [modalOpen, setModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const canWriteRegions = useStaffPermission('region:write'); - - const organizations = orgResult?.organizations ?? []; - - const openCreate = () => { - setEditing(null); - setModalOpen(true); - }; - const openEdit = (region: OrganizationRegion) => { - setEditing(region); - setModalOpen(true); - }; - - return ( -
-
-
-

Custom regions

-

- Placement shapes owned by one organization: the datacenters one unit occupies. Every cluster in the - organization can deploy one, choosing how many units it wants. Internal only — customers never see these. -

-
- {canWriteRegions && organizationId && ( - - )} -
- -
- - {orgResult?.truncated && ( -

- Too many organizations to list them all — some may be missing from this picker. -

- )} -
- -
- {!organizationId - ?

Choose an organization to see its custom regions.

- : isLoading - ?

Loading custom regions…

- : isError - ?

Couldn't load custom regions.

- : !regions || regions.length === 0 - ?

No custom regions yet for this organization.

- : ( - - - - Name - Datacenters per unit - Fallback pool - - - - - {regions.map((region) => ( - - - - {region.name} - {region.active === false && Inactive} - -
{region.id}
-
- - - - {region.fallbackGroup ?? 'None (forced)'} - - {canWriteRegions && ( - - )} - -
- ))} -
-
- )} -
- - {organizationId && ( - - )} -
- ); -} From c10101336f19f6f34cd11ee343779d57a5e9fd88 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 12:59:05 -0400 Subject: [PATCH 14/24] fix(clusters): an option description too long for its select ends in an ellipsis Same change as f1b45be6 on epic/pricing-updates, brought to stage so branches cut from stage stop showing a deployment or performance description running under the select's chevron. (cherry picked from commit f1b45be69975817fdeb307bf8c7fb0ae334c6fb4) Co-Authored-By: Claude Opus 5.5 --- .../clusters/upsert/fields/ClusterDeploymentDescription.tsx | 2 +- .../clusters/upsert/fields/ClusterPerformanceDescription.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/clusters/upsert/fields/ClusterDeploymentDescription.tsx b/src/features/clusters/upsert/fields/ClusterDeploymentDescription.tsx index 6debe3afd..715179169 100644 --- a/src/features/clusters/upsert/fields/ClusterDeploymentDescription.tsx +++ b/src/features/clusters/upsert/fields/ClusterDeploymentDescription.tsx @@ -55,7 +55,7 @@ export function ClusterDeploymentDescription({ >
{deploymentDescription}
{DEPLOYMENT_FULL_DESCRIPTION[deploymentDescription] && ( -
{DEPLOYMENT_FULL_DESCRIPTION[deploymentDescription]}
+
{DEPLOYMENT_FULL_DESCRIPTION[deploymentDescription]}
)}
))} diff --git a/src/features/clusters/upsert/fields/ClusterPerformanceDescription.tsx b/src/features/clusters/upsert/fields/ClusterPerformanceDescription.tsx index 565d4bafc..7f702f0e5 100644 --- a/src/features/clusters/upsert/fields/ClusterPerformanceDescription.tsx +++ b/src/features/clusters/upsert/fields/ClusterPerformanceDescription.tsx @@ -60,7 +60,7 @@ export function ClusterPerformanceDescription({ {performanceDescription.isPremium && Premium} {performanceDescription.description && ( -
{performanceDescription.description}
+
{performanceDescription.description}
)}
))} From fb363df555c176ae8ede76bc1d7baa2ba8bea193 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 12:54:24 -0400 Subject: [PATCH 15/24] fix(clusters): drop the picker hint that pointed at the removed admin page A custom region with no datacenters on the organization's provider still lists disabled with the reason, but no longer sends staff to the Custom regions admin page, which this PR no longer adds. Co-Authored-By: Claude Opus 5.5 --- src/features/clusters/upsert/components/RegionFormInputs.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/clusters/upsert/components/RegionFormInputs.tsx b/src/features/clusters/upsert/components/RegionFormInputs.tsx index 98a4fc4ba..e9f585468 100644 --- a/src/features/clusters/upsert/components/RegionFormInputs.tsx +++ b/src/features/clusters/upsert/components/RegionFormInputs.tsx @@ -241,8 +241,7 @@ export function RegionFormInputs({ {!region.active && Inactive} {noDatacenters && ( - no {cloudProvider === 'linode' ? 'Linode' : 'GCP'}{' '} - datacenters — edit it under Admin › Custom regions + lists no {cloudProvider === 'linode' ? 'Linode' : 'GCP'} datacenters )} {onAnotherRow && ( From 137159f05f38e55cc65732265fdf7146b660f605 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 13 Oct 2026 21:15:15 -0400 Subject: [PATCH 16/24] test(clusters): give the refused-edit test the custom-region props The refused-edit test landed on stage while this branch was open, so it renders ClusterForm without the four props this branch makes required (organizationRegions, canUseCustomRegions, canDefineCustomRegions and lockedOrganizationRegionIds), and tsc fails once the branch is rebased onto stage. Pass the no-custom-regions values the other ClusterForm tests use. Co-Authored-By: Claude Opus 5.5 --- src/features/clusters/upsert/ClusterForm.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/features/clusters/upsert/ClusterForm.test.tsx b/src/features/clusters/upsert/ClusterForm.test.tsx index 5d169cd39..dc659a8df 100644 --- a/src/features/clusters/upsert/ClusterForm.test.tsx +++ b/src/features/clusters/upsert/ClusterForm.test.tsx @@ -71,6 +71,10 @@ function renderVersionForm() { mode="version" organization={{ id: 'org-1' } as Organization} organizationId="org-1" + organizationRegions={[]} + canUseCustomRegions={false} + canDefineCustomRegions={false} + lockedOrganizationRegionIds={[]} partialUpgrade={{ latest: '5.0.2', behindCount: 1, total: 2, ambiguous: false }} planTypes={[plan]} regionLocationsColocated={[]} From 99ffe8402643676dd1969332c2706c7865cce571 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 22 Oct 2026 16:13:25 -0400 Subject: [PATCH 17/24] fix(clusters): hide Define custom region on a central manager without custom regions The custom-region list read a 404 as "no regions", so on a central manager that predates the resource staff still saw Define custom region, whose save would 404. The list now answers null there; the cluster form treats that like an empty list but stops offering the define action. Co-Authored-By: Claude Opus 5.5 --- .../queries/getOrganizationRegions.test.ts | 35 +++++++++++++++++++ .../queries/getOrganizationRegions.ts | 8 ++--- src/features/clusters/upsert/index.tsx | 9 ++--- 3 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 src/features/admin/organizationRegions/queries/getOrganizationRegions.test.ts diff --git a/src/features/admin/organizationRegions/queries/getOrganizationRegions.test.ts b/src/features/admin/organizationRegions/queries/getOrganizationRegions.test.ts new file mode 100644 index 000000000..dee59e354 --- /dev/null +++ b/src/features/admin/organizationRegions/queries/getOrganizationRegions.test.ts @@ -0,0 +1,35 @@ +import { AxiosError, AxiosHeaders } from 'axios'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getOrganizationRegions } from './getOrganizationRegions'; + +const get = vi.hoisted(() => vi.fn()); +vi.mock('@/config/apiClient', () => ({ apiClient: { get } })); + +afterEach(() => get.mockReset()); + +const httpError = (status: number) => + new AxiosError('failed', String(status), undefined, undefined, { + status, + statusText: '', + headers: {}, + config: { headers: new AxiosHeaders() }, + data: {}, + }); + +describe('getOrganizationRegions', () => { + it("returns the organization's rows", async () => { + get.mockResolvedValue({ data: [{ id: 'oreg-1' }] }); + await expect(getOrganizationRegions('org-1')).resolves.toEqual([{ id: 'oreg-1' }]); + expect(get).toHaveBeenCalledWith('/OrganizationRegion/?organizationId=org-1'); + }); + + it('returns null when the central manager has no custom regions (404)', async () => { + get.mockRejectedValue(httpError(404)); + await expect(getOrganizationRegions('org-1')).resolves.toBeNull(); + }); + + it('rethrows any other failure', async () => { + get.mockRejectedValue(httpError(500)); + await expect(getOrganizationRegions('org-1')).rejects.toThrow('failed'); + }); +}); diff --git a/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts b/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts index 8dbfee133..ef08950b1 100644 --- a/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts +++ b/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts @@ -5,10 +5,10 @@ import { isAxiosError } from 'axios'; /** * GET /OrganizationRegion/?organizationId= → the organization's custom regions (members and staff). - * A central-manager that predates the resource answers 404; that reads as "none", so a catalog-only - * cluster form keeps working across the rollout. + * `null` when the central-manager predates the resource (404): callers read that as "none", so a + * catalog-only cluster form keeps working across the rollout, and offer nothing that would write one. */ -export async function getOrganizationRegions(organizationId: string): Promise { +export async function getOrganizationRegions(organizationId: string): Promise { try { const { data } = await apiClient.get( `/OrganizationRegion/?organizationId=${encodeURIComponent(organizationId)}` as '/Region/', @@ -16,7 +16,7 @@ export async function getOrganizationRegions(organizationId: string): Promise (cluster?.plans ?? []).map(plan => plan.regionId).filter((id): id is string => isOrganizationRegionId(id)), From 8411d294fe7c707487d70bdbddb667f3a5a1ef2c Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 22 Oct 2026 16:14:07 -0400 Subject: [PATCH 18/24] feat(clusters): show a custom region's units and datacenters on the cluster overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview showed only a cluster's total instance count, which hides what a custom region deploys. A cluster with custom regions now lists each one with its units and per-unit datacenters on the organization's provider ("EU edge · 2 units of fr-par ×2 · it-mil"); catalog-only clusters are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/features/cluster/ClusterHome.tsx | 2 + .../components/ClusterCustomRegions.test.tsx | 73 +++++++++++++++++++ .../components/ClusterCustomRegions.tsx | 43 +++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/features/cluster/components/ClusterCustomRegions.test.tsx create mode 100644 src/features/cluster/components/ClusterCustomRegions.tsx diff --git a/src/features/cluster/ClusterHome.tsx b/src/features/cluster/ClusterHome.tsx index 02c56aa5a..d0955c363 100644 --- a/src/features/cluster/ClusterHome.tsx +++ b/src/features/cluster/ClusterHome.tsx @@ -5,6 +5,7 @@ import { activeClusterStatuses, deletedClusterStatuses, isDeletedCluster } from import { getInstanceClient } from '@/config/getInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; import { allClusterInstancesRunning } from '@/features/cluster/allInstancesRunning'; +import { ClusterCustomRegions } from '@/features/cluster/components/ClusterCustomRegions'; import { ClusterPageLayout } from '@/features/cluster/components/ClusterPageLayout'; import { ClusterTerminated } from '@/features/cluster/components/ClusterTerminated'; import { ClusterUsageCard } from '@/features/cluster/components/ClusterUsageCard'; @@ -186,6 +187,7 @@ function ManagedClusterHome({ cluster, update }: { cluster: Cluster; update: boo
+
diff --git a/src/features/cluster/components/ClusterCustomRegions.test.tsx b/src/features/cluster/components/ClusterCustomRegions.test.tsx new file mode 100644 index 000000000..4e4f105a4 --- /dev/null +++ b/src/features/cluster/components/ClusterCustomRegions.test.tsx @@ -0,0 +1,73 @@ +/** + * @vitest-environment jsdom + */ +import { ClusterCustomRegions } from '@/features/cluster/components/ClusterCustomRegions'; +import type { Cluster, Organization, OrganizationRegion } from '@/integrations/api/api.patch'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ + regions: undefined as OrganizationRegion[] | null | undefined, + organization: undefined as Partial | undefined, +})); +vi.mock('@tanstack/react-query', () => ({ + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + return { data: queryKey[0] === 'organizationRegions' ? state.regions : state.organization }; + }, + queryOptions: (options: unknown) => options, +})); + +afterEach(() => { + cleanup(); + state.regions = undefined; + state.organization = undefined; +}); + +const region = (over: Partial = {}): OrganizationRegion => ({ + id: 'oreg-1', + organizationId: 'org-1', + name: 'EU edge', + placement: { linode: ['fr-par', 'fr-par', 'it-mil'], gcp: ['europe-west1-b'] }, + fallbackGroup: null, + active: true, + ...over, +}); + +const cluster = ( + plans: Cluster['plans'], +): Cluster => ({ id: 'clu-1', name: 'c', organizationId: 'org-1', plans } as Cluster); + +describe('ClusterCustomRegions', () => { + it('renders nothing for a catalog-only cluster', () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(''); + }); + + it("shows each custom region's units and its shape on the organization's provider", () => { + state.regions = [region()]; + state.organization = { id: 'org-1', channel: 'Akamai' }; + render( + , + ); + expect(screen.getByRole('list', { name: 'Custom regions' }).textContent).toBe( + 'EU edge · 2 units of fr-par ×2 · it-mil', + ); + }); + + it('reads the GCP list for an organization outside the Akamai channel, and one unit by default', () => { + state.regions = [region()]; + state.organization = { id: 'org-1', channel: undefined }; + render(); + expect(screen.getByRole('listitem').textContent).toBe('EU edge · 1 unit of europe-west1-b'); + }); + + it('falls back to the stored plan name when the region list is unavailable', () => { + state.regions = null; + render( + , + ); + expect(screen.getByRole('listitem').textContent).toBe('EU edge · 3 units'); + }); +}); diff --git a/src/features/cluster/components/ClusterCustomRegions.tsx b/src/features/cluster/components/ClusterCustomRegions.tsx new file mode 100644 index 000000000..07d18050b --- /dev/null +++ b/src/features/cluster/components/ClusterCustomRegions.tsx @@ -0,0 +1,43 @@ +import { getOrganizationRegionsQueryOptions } from '@/features/admin/organizationRegions/queries/getOrganizationRegions'; +import { + describeShape, + isOrganizationRegionId, + organizationRegionShape, +} from '@/features/clusters/upsert/lib/regionLookup'; +import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery'; +import { Cluster } from '@/integrations/api/api.patch'; +import { pluralize } from '@/lib/pluralize'; +import { useQuery } from '@tanstack/react-query'; + +// The instance count alone hides what a custom region deploys: how many units, of which datacenters. +export function ClusterCustomRegions({ cluster }: { cluster: Cluster }) { + const customPlans = (cluster.plans ?? []).filter((plan) => isOrganizationRegionId(plan.regionId)); + const organizationId = customPlans.length ? cluster.organizationId : undefined; + const { data: regions } = useQuery(getOrganizationRegionsQueryOptions(organizationId)); + const { data: organization } = useQuery({ + ...getOrganizationQueryOptions(organizationId), + enabled: !!organizationId, + }); + if (!customPlans.length) { + return null; + } + const provider = organization?.channel === 'Akamai' ? 'linode' : 'gcp'; + const regionsById = new Map((regions ?? []).map((region) => [region.id, region])); + return ( +
    + {customPlans.map((plan) => { + const region = regionsById.get(plan.regionId!); + const units = plan.quantity ?? 1; + const shape = region ? describeShape(organizationRegionShape(region, provider)) : ''; + return ( +
  • + {region?.name ?? plan.region ?? plan.regionId} + {' · '} + {pluralize(units, 'unit', 'units')} + {shape && {` of ${shape}`}} +
  • + ); + })} +
+ ); +} From 4d1923a3137d7bd8a82b93b09640a31efbbf0487 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 22 Oct 2026 16:28:26 -0400 Subject: [PATCH 19/24] fix(clusters): keep custom regions on the form when the catalog has none Co-Authored-By: Claude Opus 5.5 --- .../clusters/upsert/ClusterRegions.test.tsx | 79 +++++++++++++++++++ .../clusters/upsert/ClusterRegions.tsx | 8 +- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/features/clusters/upsert/ClusterRegions.test.tsx diff --git a/src/features/clusters/upsert/ClusterRegions.test.tsx b/src/features/clusters/upsert/ClusterRegions.test.tsx new file mode 100644 index 000000000..2e802fde1 --- /dev/null +++ b/src/features/clusters/upsert/ClusterRegions.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment jsdom + */ +import { Form } from '@/components/ui/form/Form'; +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { useForm } from 'react-hook-form'; +import { describe, expect, it } from 'vitest'; +import { ClusterRegions } from './ClusterRegions'; +import { buildRegionLookup } from './lib/regionLookup'; +import { UpsertClusterSchemaType } from './upsertClusterSchema'; + +const orgRegion: OrganizationRegion = { + id: 'oreg-1', + organizationId: 'org-1', + name: 'EU edge', + placement: { gcp: ['europe-west1-b', 'europe-west4-a'] }, + fallbackGroup: null, + active: true, +}; + +const range = { min: 0, max: 1 }; + +function Harness({ organizationRegions, canUseCustomRegions }: { + organizationRegions: OrganizationRegion[]; + canUseCustomRegions: boolean; +}) { + const form = useForm({ + defaultValues: { + clusterName: 'c', + abbreviatedName: '', + deploymentDescription: 'Shared', + performanceDescription: 'Basic', + regionPlans: [{ regionId: '' }], + instances: [], + }, + }); + return ( + + + + ); +} + +function renderRegions(props: Parameters[0]) { + return render( + + + , + ); +} + +describe('ClusterRegions with an empty catalog', () => { + it('keeps the region rows when a custom region can be deployed instead', () => { + renderRegions({ organizationRegions: [orgRegion], canUseCustomRegions: true }); + expect(screen.queryByText('No Regions Available')).toBeNull(); + }); + + it('says no regions are available when no custom region can stand in', () => { + renderRegions({ organizationRegions: [orgRegion], canUseCustomRegions: false }); + expect(screen.getByText('No Regions Available')).toBeTruthy(); + }); +}); diff --git a/src/features/clusters/upsert/ClusterRegions.tsx b/src/features/clusters/upsert/ClusterRegions.tsx index 574edf77b..ce1d66c0f 100644 --- a/src/features/clusters/upsert/ClusterRegions.tsx +++ b/src/features/clusters/upsert/ClusterRegions.tsx @@ -97,7 +97,13 @@ export function ClusterRegions({ toast.success(`${region.name} is ready to pick under Custom regions`); }, []); - if (!regionLocations?.length) { + // An empty catalog leaves nothing to deploy only when no custom region can stand in for it. + const offersCustomRegion = lockedOrganizationRegionIds.length > 0 || canDefineCustomRegions + || (canUseCustomRegions + && [...regionLookup.values()].some(region => + region.kind === 'organization' && region.active && region.instanceCount > 0 + )); + if (!regionLocations?.length && !offersCustomRegion) { return (
Date: Thu, 22 Oct 2026 16:28:57 -0400 Subject: [PATCH 20/24] fix(admin): report custom-region write failures and keep the lost-write guidance Co-Authored-By: Claude Opus 5.5 --- .../OrganizationRegionFormModal.test.tsx | 76 +++++++++++++++++++ .../OrganizationRegionFormModal.tsx | 13 +--- .../useOrganizationRegionMutations.ts | 6 +- 3 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 src/features/admin/organizationRegions/components/OrganizationRegionFormModal.test.tsx diff --git a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.test.tsx b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.test.tsx new file mode 100644 index 000000000..b04703b11 --- /dev/null +++ b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.test.tsx @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + */ +import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { get, patch } = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() })); +vi.mock('@/config/apiClient', () => ({ apiClient: { get, patch, post: vi.fn() } })); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { UNANSWERED_WRITE_MESSAGE } from '@/integrations/api/unansweredWrite'; +import { mutationErrorHandler } from '@/react-query/queryClient'; +import { toast } from 'sonner'; +import { OrganizationRegionFormModal } from './OrganizationRegionFormModal'; + +const region: OrganizationRegion = { + id: 'oreg-1', + organizationId: 'org-1', + name: 'EU edge', + placement: { gcp: ['europe-west1-b'] }, + fallbackGroup: null, + active: true, +}; + +/** A PATCH that reached the server but whose response never came back. */ +function lostResponse(): AxiosError { + return { + isAxiosError: true, + code: 'ERR_NETWORK', + message: 'Network Error', + config: { method: 'patch', url: '/OrganizationRegion/oreg-1' }, + } as AxiosError; +} + +let consoleError: ReturnType; +beforeEach(() => { + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + get.mockImplementation(async (url: string) => { + if (url === '/Location/') { + return { data: [{ location: 'europe-west1-b', locationName: 'Belgium', cloudProvider: 'gcp', regions: [] }] }; + } + if (url.startsWith('/Organization/')) { + return { data: { id: 'org-1', name: 'Org', channel: 'bravo' } }; + } + return { data: { ...region, clusters: [] } }; + }); +}); +afterEach(() => { + consoleError.mockRestore(); + vi.resetAllMocks(); +}); + +describe('OrganizationRegionFormModal', () => { + it('tells the user a lost write may have applied, and still reports it', async () => { + patch.mockRejectedValue(lostResponse()); + const client = new QueryClient({ + mutationCache: new MutationCache({ onError: mutationErrorHandler }), + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + + + , + ); + + fireEvent.change(await screen.findByDisplayValue('EU edge'), { target: { value: 'EU core' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + expect((await screen.findByRole('alert')).textContent).toBe(UNANSWERED_WRITE_MESSAGE); + await waitFor(() => expect(consoleError).toHaveBeenCalledWith(expect.objectContaining({ code: 'ERR_NETWORK' }))); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx index e62125042..c393ef6d2 100644 --- a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx +++ b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx @@ -31,6 +31,7 @@ import { MultiSelect, MultiSelectOption } from '@/features/admin/regions/compone import { getLocationsQueryOptions } from '@/features/admin/regions/queries/getLocations'; import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery'; import { OrganizationRegion } from '@/integrations/api/api.patch'; +import { describeError } from '@/react-query/queryClient'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { isAxiosError } from 'axios'; @@ -51,16 +52,6 @@ interface OrganizationRegionFormModalProps { onSaved?: (region: OrganizationRegion) => void; } -function describeApiError(error: unknown): string { - if (isAxiosError(error)) { - const title = (error.response?.data as { title?: string } | undefined)?.title; - if (title) { - return title; - } - } - return error instanceof Error ? error.message : 'Request failed'; -} - export function OrganizationRegionFormModal( { open, onOpenChange, organizationId, region, onSaved }: OrganizationRegionFormModalProps, ) { @@ -152,7 +143,7 @@ export function OrganizationRegionFormModal( onSaved?.(saved); }; const onError = (error: unknown) => { - const message = describeApiError(error); + const { message } = describeError(error); const status = isAxiosError(error) ? error.response?.status : undefined; // 409 is either the name collision or the freeze; both belong on the form, not a toast. form.setError(status === 409 && !frozen ? 'name' : 'root', { message }); diff --git a/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts b/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts index 03ba1bf7d..a06a18fbd 100644 --- a/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts +++ b/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts @@ -19,13 +19,16 @@ export async function updateOrganizationRegion( return data as unknown as OrganizationRegion; } -// The modal puts failures on the form; the global handler must not toast them a second time. +// The modal puts failures on the form, so the global handler must not toast them a second time; +// skipping it also skips its error report, which onError keeps. const meta = { skipGlobalErrorToast: true }; +const onError = (error: Error) => console.error(error); export function useCreateOrganizationRegionMutation() { return useMutation({ mutationFn: createOrganizationRegion, meta, + onError, }); } @@ -33,5 +36,6 @@ export function useUpdateOrganizationRegionMutation() { return useMutation({ mutationFn: updateOrganizationRegion, meta, + onError, }); } From 24214abd76403705356df36f42a67cba31a28d75 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 22 Oct 2026 16:34:35 -0400 Subject: [PATCH 21/24] fix(clusters): open retries and stale drafts instead of refusing or dropping regions Co-Authored-By: Claude Opus 5.5 --- src/features/clusters/upsert/index.test.tsx | 113 ++++++++++++++++++ src/features/clusters/upsert/index.tsx | 60 ++++++---- .../upsert/lib/regionPlanDefaults.test.ts | 18 ++- .../clusters/upsert/lib/regionPlanDefaults.ts | 26 ++-- 4 files changed, 177 insertions(+), 40 deletions(-) create mode 100644 src/features/clusters/upsert/index.test.tsx diff --git a/src/features/clusters/upsert/index.test.tsx b/src/features/clusters/upsert/index.test.tsx new file mode 100644 index 000000000..e62ef7004 --- /dev/null +++ b/src/features/clusters/upsert/index.test.tsx @@ -0,0 +1,113 @@ +/** + * @vitest-environment jsdom + */ +import { SchemaPlan, SchemaRegion } from '@/integrations/api/api.gen'; +import { Cluster, Organization } from '@/integrations/api/api.patch'; +import { LocalStorageKeys } from '@/lib/storage/localStorageKeys'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const route = vi.hoisted(() => ({ + params: {} as { organizationId: string; clusterId?: string }, + context: {} as { organization: Organization; cluster?: Cluster }, +})); +vi.mock('@tanstack/react-router', () => ({ + useParams: () => route.params, + useRouteContext: () => route.context, +})); +vi.mock('@/hooks/useAuth', () => ({ useStaffPermission: () => true })); +vi.mock('@/hooks/usePermissions', () => ({ + useOrganizationClusterPermissions: () => ({ create: true, update: true }), +})); +vi.mock('@/components/SubNavMenu', () => ({ SubNavMenu: () => null })); +vi.mock( + '@/components/SubNavSimpleLayout', + () => ({ SubNavSimpleLayout: ({ children }: { children: ReactNode }) => children }), +); +vi.mock('@/features/cluster/components/ClusterPageLayout', () => ({ + ClusterPageLayout: ({ children }: { children: ReactNode }) => children, +})); +vi.mock('@/components/ContactUs', () => ({ ContactUs: () => 'contact us' })); +vi.mock('@/components/ErrorComponent', () => ({ ErrorComponent: ({ title }: { title: string }) =>

{title}

})); +vi.mock('./ClusterForm', () => ({ + ClusterForm: ({ defaultValues }: { defaultValues: { regionPlans: unknown } }) => ( + {JSON.stringify(defaultValues.regionPlans)} + ), +})); + +const colocatedPlan = { id: 'plan-colo', deploymentType: 'colocated', deploymentDescription: 'Shared' } as SchemaPlan; +const dedicatedPlan = { id: 'plan-ded', deploymentType: 'dedicated', deploymentDescription: 'Dedicated' } as SchemaPlan; +const usColocated = { id: 'us-1', region: 'US', latencyDescription: 'Low', instanceCount: 2 } as SchemaRegion; +const euDedicated = { id: 'eu-ded', region: 'EU', latencyDescription: 'Low', instanceCount: 2 } as SchemaRegion; + +const answer = (key: string, data: unknown) => ({ queryKey: [key], queryFn: async () => data }); +vi.mock('@/features/cluster/queries/getPlanTypesQuery', () => ({ + getPlanTypesOptions: () => answer('plans', [colocatedPlan, dedicatedPlan]), +})); +vi.mock('@/features/clusters/queries/getRegionLocationsQuery', () => ({ + getRegionLocationsOptions: ({ availableHosts }: { availableHosts?: boolean }) => + answer(`regions-${availableHosts}`, availableHosts ? [usColocated] : [usColocated, euDedicated]), +})); +vi.mock('@/features/clusters/queries/getHarperVersionsQuery', () => ({ + getHarperVersionsOptions: () => answer('versions', { value: [{ name: 'current', version: '5.3.2' }] }), +})); +vi.mock('@/features/admin/organizationRegions/queries/getOrganizationRegions', () => ({ + getOrganizationRegionsQueryOptions: () => answer('organizationRegions', []), +})); + +import { UpsertCluster } from './index'; + +const organization = { id: 'org-1', clusters: [] } as unknown as Organization; +const dedicatedCluster = { + id: 'clu-1', + name: 'ded', + status: 'FAILED', + plans: [{ regionId: 'eu-ded', planId: 'plan-ded' }], + instances: [], +} as unknown as Cluster; + +function renderUpsert() { + return render( + + + , + ); +} + +afterEach(() => localStorage.clear()); + +describe('UpsertCluster', () => { + it('retries a failed dedicated cluster from the dedicated catalog, where its regions are', async () => { + route.params = { organizationId: 'org-1' }; + route.context = { organization }; + localStorage.setItem(LocalStorageKeys.SavedClusterState, JSON.stringify(dedicatedCluster)); + renderUpsert(); + + expect((await screen.findByRole('status')).textContent).toBe('[{"regionId":"eu-ded"}]'); + }); + + it('still refuses to edit a cluster with a region it cannot resolve', async () => { + route.params = { organizationId: 'org-1', clusterId: 'clu-1' }; + route.context = { + organization, + cluster: { ...dedicatedCluster, plans: [{ regionId: 'gone', planId: 'plan-ded' }] } as unknown as Cluster, + }; + renderUpsert(); + + expect(await screen.findByText('Region Unavailable')).toBeTruthy(); + }); + + it('opens a cluster from the server when a legacy draft no longer matches the catalog', async () => { + route.params = { organizationId: 'org-1', clusterId: 'clu-1' }; + route.context = { organization, cluster: { ...dedicatedCluster, status: 'RUNNING' } as unknown as Cluster }; + localStorage.setItem( + LocalStorageKeys.SavedClusterState, + JSON.stringify({ clusterName: 'ded', regionPlans: [{ regionName: 'EU', latencyDescription: 'Lowest' }] }), + ); + renderUpsert(); + + expect((await screen.findByRole('status')).textContent).toBe('[{"regionId":"eu-ded"}]'); + }); +}); diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx index a4a380f08..5262d4de1 100644 --- a/src/features/clusters/upsert/index.tsx +++ b/src/features/clusters/upsert/index.tsx @@ -152,14 +152,13 @@ export function UpsertCluster() { return null; } - let clusterToLoad = cluster; - - const selectedPlan = planTypes?.find(planType => planType.id === cluster?.plans?.[0].planId); - const regionLocations = selectedPlan?.deploymentDescription !== 'Dedicated' - ? regionLocationsColocated - : regionLocationsDedicated; - const regionLookup = buildRegionLookup(regionLocations, organizationRegions, cloudProvider); + const lookupFor = (plans: Cluster['plans'] | undefined) => { + const plan = planTypes.find(planType => planType.id === plans?.[0]?.planId); + const catalog = plan?.deploymentDescription !== 'Dedicated' ? regionLocationsColocated : regionLocationsDedicated; + return { plan, regionLookup: buildRegionLookup(catalog, organizationRegions, cloudProvider) }; + }; + let clusterToLoad = cluster; if (savedClusterState) { if (isUpsertClusterSchema(savedClusterState)) { const draftLookup = buildRegionLookup( @@ -167,27 +166,36 @@ export function UpsertCluster() { organizationRegions, cloudProvider, ); - return { - values: { - ...savedClusterState, - clusterName: savedClusterState.clusterName || '', - abbreviatedName: savedClusterState.abbreviatedName || '', - version: savedClusterState.version, - deploymentDescription: savedClusterState.deploymentDescription || '', - performanceDescription: savedClusterState.performanceDescription || '', - fqdn: savedClusterState.fqdn || '', - regionPlans: migrateDraftRegionPlans(savedClusterState.regionPlans, draftLookup), - instances: savedClusterState.instances || [], - }, - // The draft may have lost a region the live cluster still deploys; the refusal below - // keys on the server's plans, not the draft. - unresolvedRegionIds: buildRegionPlanDefaults(cluster?.plans, regionLookup).unresolvedRegionIds, - }; + const draftRegionPlans = migrateDraftRegionPlans(savedClusterState.regionPlans, draftLookup); + if (draftRegionPlans) { + return { + values: { + ...savedClusterState, + clusterName: savedClusterState.clusterName || '', + abbreviatedName: savedClusterState.abbreviatedName || '', + version: savedClusterState.version, + deploymentDescription: savedClusterState.deploymentDescription || '', + performanceDescription: savedClusterState.performanceDescription || '', + fqdn: savedClusterState.fqdn || '', + regionPlans: draftRegionPlans, + instances: savedClusterState.instances || [], + }, + // The draft may have lost a region the live cluster still deploys; the refusal below + // keys on the server's plans, not the draft. + unresolvedRegionIds: buildRegionPlanDefaults(cluster?.plans, lookupFor(cluster?.plans).regionLookup) + .unresolvedRegionIds, + }; + } } else { clusterToLoad = savedClusterState; } } + const { plan: selectedPlan, regionLookup } = lookupFor(clusterToLoad?.plans); + const regionLocations = selectedPlan?.deploymentDescription !== 'Dedicated' + ? regionLocationsColocated + : regionLocationsDedicated; + const regionPlans: RegionPlanEntry[] = []; let unresolvedRegionIds: string[] = []; const instances: z.infer = []; @@ -271,9 +279,9 @@ export function UpsertCluster() { } // Editing with a region the form cannot resolve would save without it, which central-manager - // treats as removing that region — so the form refuses to open instead. - // A version-only edit never sends region plans, so an unresolvable one cannot be lost by it. - if (mode !== 'version' && defaultsResult?.unresolvedRegionIds.length) { + // treats as removing that region — so the form refuses to open instead. A create (retrying a failed + // cluster) removes nothing and a version-only edit sends no region plans, so both open without it. + if (clusterId && mode !== 'version' && defaultsResult?.unresolvedRegionIds.length) { return ( { ]); }); - it('drops a legacy entry the catalog no longer matches', () => { - expect(migrateDraftRegionPlans([{ regionName: 'EU', latencyDescription: 'Lowest' }, {}], lookup)).toEqual([]); + it('refuses the draft when a legacy entry no longer matches the catalog', () => { + expect( + migrateDraftRegionPlans( + [{ regionName: 'US', latencyDescription: 'Low (1 region)' }, { + regionName: 'EU', + latencyDescription: 'Lowest', + }], + lookup, + ), + ).toBeNull(); + }); + + it('skips a blank row', () => { + expect(migrateDraftRegionPlans([{ regionId: 'us-1' }, {}, { regionId: '' }], lookup)).toEqual([ + { regionId: 'us-1' }, + ]); }); it('tolerates an absent list', () => { diff --git a/src/features/clusters/upsert/lib/regionPlanDefaults.ts b/src/features/clusters/upsert/lib/regionPlanDefaults.ts index 6c104127c..5bac61a96 100644 --- a/src/features/clusters/upsert/lib/regionPlanDefaults.ts +++ b/src/features/clusters/upsert/lib/regionPlanDefaults.ts @@ -38,29 +38,31 @@ interface LegacyDraftEntry { /** * A saved draft may predate id-keyed entries: its name + latency pairs resolve through the catalog * where they still match a row. An id-keyed entry is kept as-is even when the lookup lacks it, so - * validation reports it on the row instead of the draft silently losing a selection. + * validation reports it on the row instead of the draft silently losing a selection. `null` when a + * legacy pair matches no row: saved without it, an edit would remove that region, so the caller + * discards the draft rather than open it short. */ export function migrateDraftRegionPlans( entries: readonly (Partial & LegacyDraftEntry)[] | undefined, lookup: RegionLookup, -): RegionPlanEntry[] { +): RegionPlanEntry[] | null { const migrated: RegionPlanEntry[] = []; for (const entry of entries ?? []) { if (entry.regionId) { migrated.push({ regionId: entry.regionId, ...(entry.quantity ? { quantity: entry.quantity } : {}) }); continue; } - if (entry.regionName && entry.latencyDescription) { - for (const region of lookup.values()) { - if ( - region.kind === 'catalog' && region.name === entry.regionName - && region.latencyDescription === entry.latencyDescription - ) { - migrated.push({ regionId: region.id }); - break; - } - } + if (!entry.regionName) { + continue; + } + const match = [...lookup.values()].find(region => + region.kind === 'catalog' && region.name === entry.regionName + && region.latencyDescription === entry.latencyDescription + ); + if (!match) { + return null; } + migrated.push({ regionId: match.id }); } return migrated; } From cc3c40f5fa95490d53353d0062fba0527ac4f139 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 22 Oct 2026 16:36:26 -0400 Subject: [PATCH 22/24] fix(clusters): revalidate the whole form before payment review submits Co-Authored-By: Claude Opus 5.5 --- .../clusters/upsert/ClusterForm.test.tsx | 58 +++++++++++++++++++ src/features/clusters/upsert/ClusterForm.tsx | 17 +++--- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/features/clusters/upsert/ClusterForm.test.tsx b/src/features/clusters/upsert/ClusterForm.test.tsx index dc659a8df..58f424b7b 100644 --- a/src/features/clusters/upsert/ClusterForm.test.tsx +++ b/src/features/clusters/upsert/ClusterForm.test.tsx @@ -129,6 +129,64 @@ describe('ClusterForm — a refused edit', () => { }); }); +describe('ClusterForm — payment review', () => { + it('sends a draft that is no longer valid back to the details instead of submitting it', async () => { + const queryClient = newQueryClient(); + queryClient.setQueryData(['org-1'], { id: 'org-1', type: ENTERPRISE } as Organization); + const { findByRole, findByText, queryByText } = render( + + + , + ); + const submit = await findByRole('button', { name: /Edit Cluster/ }) as HTMLButtonElement; + await waitFor(() => expect(submit.disabled).toBe(false)); + + fireEvent.click(submit); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith('Some cluster details are no longer valid. Please review them.') + ); + expect(put).not.toHaveBeenCalled(); + expect(queryByText('Payment review')).toBeNull(); + expect(await findByText('A cluster needs at least 2 instances in total.')).toBeTruthy(); + }); +}); + describe('ClusterBilling', () => { it('shows a refused edit above its buttons', () => { const queryClient = newQueryClient(); diff --git a/src/features/clusters/upsert/ClusterForm.tsx b/src/features/clusters/upsert/ClusterForm.tsx index af3b07c91..abc35ed34 100644 --- a/src/features/clusters/upsert/ClusterForm.tsx +++ b/src/features/clusters/upsert/ClusterForm.tsx @@ -477,6 +477,13 @@ export function ClusterForm({ }, [queryClient, router, navigate, organizationId, form]); const executeChangesToCluster = useCallback(async () => { + // Payment review submits without the details form, and a draft restored after a billing redirect + // can be stale: a region, a custom region's shape or the staff rules may have changed meanwhile. + if (!(await form.trigger())) { + toast.error('Some cluster details are no longer valid. Please review them.'); + setConfirmingPaymentDetails(false); + return; + } const formData = form.getValues(); const plans: ClusterUpsertRegionPlan[] = []; const plan = deploymentToPerformanceToPlan[formData.deploymentDescription][formData.performanceDescription]; @@ -494,19 +501,9 @@ export function ClusterForm({ } } else if (mode !== 'version') { for (const regionPlan of formData.regionPlans) { - // Re-resolve at submit: the catalog or the custom-region list may have changed since validation. const region = regionLookup.get(regionPlan.regionId); if (!region) { toast.error('A selected region is no longer available. Please review your regions.'); - void form.trigger(); - return; - } - // Payment review submits without re-validating, and the shape can change while the user is away. - if (region.kind === 'organization' && region.instanceCount === 0) { - toast.error( - `${region.name} has no datacenters on the organization's cloud provider. Please review your regions.`, - ); - void form.trigger(); return; } plans.push({ From 5770f731db0308a679a2629f859b9a3b462969a8 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 22 Oct 2026 16:39:24 -0400 Subject: [PATCH 23/24] fix(cluster): show a custom region's datacenters only once the provider is known Co-Authored-By: Claude Opus 5.5 --- .../components/OrganizationRegionFormModal.tsx | 1 - .../admin/regions/components/DatacenterCountSummary.tsx | 1 - .../cluster/components/ClusterCustomRegions.test.tsx | 8 ++++++++ src/features/cluster/components/ClusterCustomRegions.tsx | 5 +++-- .../clusters/upsert/components/RegionFormInputs.tsx | 1 - 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx index c393ef6d2..6f7509c2e 100644 --- a/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx +++ b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx @@ -46,7 +46,6 @@ interface OrganizationRegionFormModalProps { open: boolean; onOpenChange: (open: boolean) => void; organizationId: string; - /** The region being edited; omit/null to create a new one. */ region?: OrganizationRegion | null; /** Called with the created or updated row, after the caches are invalidated. */ onSaved?: (region: OrganizationRegion) => void; diff --git a/src/features/admin/regions/components/DatacenterCountSummary.tsx b/src/features/admin/regions/components/DatacenterCountSummary.tsx index 3b189e3f6..c7e42c19a 100644 --- a/src/features/admin/regions/components/DatacenterCountSummary.tsx +++ b/src/features/admin/regions/components/DatacenterCountSummary.tsx @@ -1,6 +1,5 @@ import { describeShape } from '@/features/clusters/upsert/lib/regionLookup'; -// The pickers allow repeats but render one chip per entry; this is where the count shows. export function DatacenterCountSummary( { datacenters, ariaLabel }: { datacenters: readonly string[]; ariaLabel: string }, ) { diff --git a/src/features/cluster/components/ClusterCustomRegions.test.tsx b/src/features/cluster/components/ClusterCustomRegions.test.tsx index 4e4f105a4..6afbc2e3d 100644 --- a/src/features/cluster/components/ClusterCustomRegions.test.tsx +++ b/src/features/cluster/components/ClusterCustomRegions.test.tsx @@ -63,6 +63,14 @@ describe('ClusterCustomRegions', () => { expect(screen.getByRole('listitem').textContent).toBe('EU edge · 1 unit of europe-west1-b'); }); + it('holds back the shape until the organization says which provider it uses', () => { + state.regions = [region()]; + render( + , + ); + expect(screen.getByRole('list', { name: 'Custom regions' }).textContent).toBe('EU edge · 2 units'); + }); + it('falls back to the stored plan name when the region list is unavailable', () => { state.regions = null; render( diff --git a/src/features/cluster/components/ClusterCustomRegions.tsx b/src/features/cluster/components/ClusterCustomRegions.tsx index 07d18050b..940662816 100644 --- a/src/features/cluster/components/ClusterCustomRegions.tsx +++ b/src/features/cluster/components/ClusterCustomRegions.tsx @@ -21,14 +21,15 @@ export function ClusterCustomRegions({ cluster }: { cluster: Cluster }) { if (!customPlans.length) { return null; } - const provider = organization?.channel === 'Akamai' ? 'linode' : 'gcp'; + // The shape is per provider: until the organization says which, showing GCP's could be wrong. + const provider = organization && (organization.channel === 'Akamai' ? 'linode' : 'gcp'); const regionsById = new Map((regions ?? []).map((region) => [region.id, region])); return (
    {customPlans.map((plan) => { const region = regionsById.get(plan.regionId!); const units = plan.quantity ?? 1; - const shape = region ? describeShape(organizationRegionShape(region, provider)) : ''; + const shape = region && provider ? describeShape(organizationRegionShape(region, provider)) : ''; return (
  • {region?.name ?? plan.region ?? plan.regionId} diff --git a/src/features/clusters/upsert/components/RegionFormInputs.tsx b/src/features/clusters/upsert/components/RegionFormInputs.tsx index e9f585468..65b86d49e 100644 --- a/src/features/clusters/upsert/components/RegionFormInputs.tsx +++ b/src/features/clusters/upsert/components/RegionFormInputs.tsx @@ -95,7 +95,6 @@ function pickLatencyDescription(options: readonly string[], preferred: string | return options.find(description => !tier ? true : description.split(' ')[0].toLowerCase() === tier) || options[0]; } -// The form stores a region id; the name and latency selects are a view over the catalog for it. export function RegionFormInputs({ control, fieldArray, From 6f24aad65c49a01db21341494d33d232e91983fa Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 22 Oct 2026 16:49:56 -0400 Subject: [PATCH 24/24] fix(clusters): say so when a saved draft cannot be restored, and start on the details Co-Authored-By: Claude Opus 5.5 --- .../clusters/upsert/ClusterRegions.tsx | 1 - src/features/clusters/upsert/index.test.tsx | 22 ++++++++++++++----- src/features/clusters/upsert/index.tsx | 20 ++++++++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/features/clusters/upsert/ClusterRegions.tsx b/src/features/clusters/upsert/ClusterRegions.tsx index ce1d66c0f..609257e4b 100644 --- a/src/features/clusters/upsert/ClusterRegions.tsx +++ b/src/features/clusters/upsert/ClusterRegions.tsx @@ -97,7 +97,6 @@ export function ClusterRegions({ toast.success(`${region.name} is ready to pick under Custom regions`); }, []); - // An empty catalog leaves nothing to deploy only when no custom region can stand in for it. const offersCustomRegion = lockedOrganizationRegionIds.length > 0 || canDefineCustomRegions || (canUseCustomRegions && [...regionLookup.values()].some(region => diff --git a/src/features/clusters/upsert/index.test.tsx b/src/features/clusters/upsert/index.test.tsx index e62ef7004..8f8552e45 100644 --- a/src/features/clusters/upsert/index.test.tsx +++ b/src/features/clusters/upsert/index.test.tsx @@ -32,10 +32,12 @@ vi.mock('@/features/cluster/components/ClusterPageLayout', () => ({ vi.mock('@/components/ContactUs', () => ({ ContactUs: () => 'contact us' })); vi.mock('@/components/ErrorComponent', () => ({ ErrorComponent: ({ title }: { title: string }) =>

    {title}

    })); vi.mock('./ClusterForm', () => ({ - ClusterForm: ({ defaultValues }: { defaultValues: { regionPlans: unknown } }) => ( - {JSON.stringify(defaultValues.regionPlans)} - ), + ClusterForm: ({ defaultValues, startOffOnBilling }: { + defaultValues: { regionPlans: unknown }; + startOffOnBilling: boolean; + }) => {JSON.stringify(defaultValues.regionPlans)}, })); +vi.mock('sonner', () => ({ toast: { warning: vi.fn() } })); const colocatedPlan = { id: 'plan-colo', deploymentType: 'colocated', deploymentDescription: 'Shared' } as SchemaPlan; const dedicatedPlan = { id: 'plan-ded', deploymentType: 'dedicated', deploymentDescription: 'Dedicated' } as SchemaPlan; @@ -57,6 +59,7 @@ vi.mock('@/features/admin/organizationRegions/queries/getOrganizationRegions', ( getOrganizationRegionsQueryOptions: () => answer('organizationRegions', []), })); +import { toast } from 'sonner'; import { UpsertCluster } from './index'; const organization = { id: 'org-1', clusters: [] } as unknown as Organization; @@ -104,10 +107,19 @@ describe('UpsertCluster', () => { route.context = { organization, cluster: { ...dedicatedCluster, status: 'RUNNING' } as unknown as Cluster }; localStorage.setItem( LocalStorageKeys.SavedClusterState, - JSON.stringify({ clusterName: 'ded', regionPlans: [{ regionName: 'EU', latencyDescription: 'Lowest' }] }), + JSON.stringify({ + clusterName: 'ded', + skipToBilling: true, + regionPlans: [{ regionName: 'EU', latencyDescription: 'Lowest' }], + }), ); renderUpsert(); - expect((await screen.findByRole('status')).textContent).toBe('[{"regionId":"eu-ded"}]'); + const form = await screen.findByRole('status'); + expect(form.textContent).toBe('[{"regionId":"eu-ded"}]'); + expect(form.getAttribute('data-billing')).toBe('false'); + expect(toast.warning).toHaveBeenCalledWith( + 'Your saved cluster changes could not be restored. Please make them again.', + ); }); }); diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx index 5262d4de1..671bd6e5e 100644 --- a/src/features/clusters/upsert/index.tsx +++ b/src/features/clusters/upsert/index.tsx @@ -21,7 +21,8 @@ import { groupThenKeyBy } from '@/lib/groupThenKeyBy'; import { LocalStorageKeys } from '@/lib/storage/localStorageKeys'; import { useQuery } from '@tanstack/react-query'; import { useParams, useRouteContext } from '@tanstack/react-router'; -import { ReactNode, useMemo } from 'react'; +import { ReactNode, useEffect, useMemo } from 'react'; +import { toast } from 'sonner'; import { z } from 'zod'; import { ClusterForm } from './ClusterForm'; import { isUpsertClusterSchema } from './isUpsertClusterSchema'; @@ -144,7 +145,9 @@ export function UpsertCluster() { [planTypes], ); - const defaultsResult = useMemo(() => { + const defaultsResult = useMemo< + null | { values: UpsertClusterSchemaType; unresolvedRegionIds: string[]; draftDiscarded: boolean } + >(() => { if ( !planTypes || !harperVersions || !regionLocationsColocated || !regionLocationsDedicated || !organizationRegions || (clusterId && !cluster) @@ -159,6 +162,7 @@ export function UpsertCluster() { }; let clusterToLoad = cluster; + let draftDiscarded = false; if (savedClusterState) { if (isUpsertClusterSchema(savedClusterState)) { const draftLookup = buildRegionLookup( @@ -184,8 +188,10 @@ export function UpsertCluster() { // keys on the server's plans, not the draft. unresolvedRegionIds: buildRegionPlanDefaults(cluster?.plans, lookupFor(cluster?.plans).regionLookup) .unresolvedRegionIds, + draftDiscarded, }; } + draftDiscarded = true; } else { clusterToLoad = savedClusterState; } @@ -251,6 +257,7 @@ export function UpsertCluster() { regionPlans, }, unresolvedRegionIds, + draftDiscarded, }; }, [ alreadyUsingFree, @@ -267,6 +274,12 @@ export function UpsertCluster() { savedClusterState, ]); const defaultValues = defaultsResult?.values ?? null; + const draftDiscarded = defaultsResult?.draftDiscarded ?? false; + useEffect(() => { + if (draftDiscarded) { + toast.warning('Your saved cluster changes could not be restored. Please make them again.'); + } + }, [draftDiscarded]); const isLoading = !defaultValues || !organization || !planTypes || !regionLocationsColocated || !regionLocationsDedicated || !organizationRegions; @@ -361,7 +374,8 @@ export function UpsertCluster() { regionLocationsColocated={regionLocationsColocated} regionLocationsDedicated={regionLocationsDedicated} setSavedClusterState={setSavedClusterState} - startOffOnBilling={isUpsertClusterSchema(savedClusterState) && savedClusterState.skipToBilling === true} + startOffOnBilling={!draftDiscarded && isUpsertClusterSchema(savedClusterState) + && savedClusterState.skipToBilling === true} /> );