diff --git a/src/features/admin/__tests__/adminShellVisibility.test.ts b/src/features/admin/__tests__/adminShellVisibility.test.ts index 6a2df4813..382691afd 100644 --- a/src/features/admin/__tests__/adminShellVisibility.test.ts +++ b/src/features/admin/__tests__/adminShellVisibility.test.ts @@ -21,6 +21,7 @@ describe('admin section visibility', () => { 'Regions', ]); expect(labels(user('fabric_readonly', ['region:read']))).toEqual(['Regions']); + expect(labels(user('fabric_readonly', ['systemStatus:write']))).toEqual(['Notifications']); }); it('hides the section entirely from customers', () => { diff --git a/src/features/admin/organizationRegions/OrganizationRegionFormSchema.test.ts b/src/features/admin/organizationRegions/OrganizationRegionFormSchema.test.ts new file mode 100644 index 000000000..d90f61c6a --- /dev/null +++ b/src/features/admin/organizationRegions/OrganizationRegionFormSchema.test.ts @@ -0,0 +1,94 @@ +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', + active: true, +}; + +const valid = { + name: 'EU edge', + linodeDatacenters: ['fr-par'], + gcpDatacenters: [], + fallbackGroup: NO_FALLBACK, + 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('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', + active: true, + }); + expect(toFormValues(null)).toEqual({ + name: '', + linodeDatacenters: [], + gcpDatacenters: [], + fallbackGroup: NO_FALLBACK, + 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, + 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..cdc214851 --- /dev/null +++ b/src/features/admin/organizationRegions/OrganizationRegionFormSchema.ts @@ -0,0 +1,72 @@ +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'] as const; + +/** The form's "no fallback pool" choice; the API stores null for it. */ +export const NO_FALLBACK = ''; + +// A repeated datacenter asks for another instance there. +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(), + 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, + 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, + active: values.active, + }; +} + +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.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..e62125042 --- /dev/null +++ b/src/features/admin/organizationRegions/components/OrganizationRegionFormModal.tsx @@ -0,0 +1,351 @@ +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 { 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'; +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 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), + 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's rule that a fallback pool covers every listed datacenter, as an early + // form error only: skipped while the Location catalog is absent, and when the shape and pool are + // 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) + ); + 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 and fallback are fixed while a cluster runs on this region; create a + new region to change them. +

+ )} +
+ { + /* 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); + }} + > + ( + + Name + + + + + + )} + /> +
+ ( + + + Linode datacenters{provider === 'linode' && ' — this organization deploys here'} + + + + + + + + )} + /> + ( + + + GCP datacenters{provider === 'gcp' && ' — this organization deploys here'} + + + + + + + + )} + /> +
+

+ 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."} +

+
+ ( + + Fallback pool + + + +

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

+ +
+ )} + /> +
+ ( + +
+ + 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/mutations/useOrganizationRegionMutations.ts b/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts new file mode 100644 index 000000000..03ba1bf7d --- /dev/null +++ b/src/features/admin/organizationRegions/mutations/useOrganizationRegionMutations.ts @@ -0,0 +1,37 @@ +import { apiClient } from '@/config/apiClient'; +import { OrganizationRegion, OrganizationRegionPatch, OrganizationRegionPayload } from '@/integrations/api/api.patch'; +import { useMutation } from '@tanstack/react-query'; + +export async function createOrganizationRegion(payload: OrganizationRegionPayload): Promise { + const { data } = await apiClient.post('/OrganizationRegion/' as '/Region/', payload as never); + return data as unknown as OrganizationRegion; +} + +// Send only what changed: while a live cluster references the row, central-manager 409s any write to +// a frozen field, so resubmitting one unchanged 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; +} + +// The modal puts failures on the form; the global handler must not toast them a second time. +const meta = { skipGlobalErrorToast: true }; + +export function useCreateOrganizationRegionMutation() { + return useMutation({ + mutationFn: createOrganizationRegion, + meta, + }); +} + +export function useUpdateOrganizationRegionMutation() { + return useMutation({ + mutationFn: updateOrganizationRegion, + meta, + }); +} diff --git a/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts b/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts new file mode 100644 index 000000000..8dbfee133 --- /dev/null +++ b/src/features/admin/organizationRegions/queries/getOrganizationRegions.ts @@ -0,0 +1,49 @@ +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; + } +} + +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..3b189e3f6 --- /dev/null +++ b/src/features/admin/regions/components/DatacenterCountSummary.tsx @@ -0,0 +1,15 @@ +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 }, +) { + 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/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 58e1bd0c5..1fd49b012 100644 --- a/src/features/clusters/upsert/ClusterDetails.tsx +++ b/src/features/clusters/upsert/ClusterDetails.tsx @@ -18,6 +18,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 { @@ -33,7 +34,12 @@ interface ClusterDetailsProps { mode: 'version' | undefined; partialUpgrade: PartialUpgrade | null; regionLocations: SchemaRegion[] | undefined; + regionLookup: RegionLookup; regionNameToLatencyToRegion: Record>; + organizationId: string; + canUseCustomRegions: boolean; + canDefineCustomRegions: boolean; + lockedOrganizationRegionIds: string[]; selectedDeployment: string; selectedPerformance: string; selectedPlan: SchemaPlan | undefined; @@ -53,7 +59,12 @@ export function ClusterDetails({ mode, partialUpgrade, regionLocations, + regionLookup, regionNameToLatencyToRegion, + organizationId, + canUseCustomRegions, + canDefineCustomRegions, + lockedOrganizationRegionIds, selectedDeployment, selectedPerformance, selectedPlan, @@ -241,7 +252,12 @@ export function ClusterDetails({ buildRegionLookup(regionLocationsColocated, organizationRegions, cloudProvider), + [cloudProvider, organizationRegions, regionLocationsColocated], + ); + const dedicatedRegionLookup = useMemo( + () => buildRegionLookup(regionLocationsDedicated, organizationRegions, cloudProvider), + [cloudProvider, organizationRegions, regionLocationsDedicated], + ); + const preexistingOrganizationRegionIds = useMemo(() => new Set(lockedOrganizationRegionIds), [ + lockedOrganizationRegionIds, + ]); const refineZod = useCallback((data: UpsertClusterSchemaType, ctx: z.RefinementCtx) => { const names = new Set(); const selectedPlan = deploymentToPerformanceToPlan?.[data.deploymentDescription]?.[data.performanceDescription]; const isSelfManaged = data.deploymentDescription === 'Self-Hosted'; + // A version edit submits neither regions nor instances, so a stale draft cannot block it. + if (mode === 'version') { + return; + } if (isSelfManaged) { for (let i = 0; i < data.instances.length; i++) { const fqdn = calculateInstanceFQDN(data.instances[i]); @@ -124,53 +150,99 @@ 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 (region.instanceCount === 0) { + ctx.addIssue({ + code: 'custom', + path: [`regionPlans.${i}.regionId`], + message: "This custom region has no datacenters on the organization's cloud provider.", + }); + } + 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, + mode, + preexistingOrganizationRegionIds, ]); const form = useForm({ @@ -200,6 +272,9 @@ export function ClusterForm({ const regionLocations = selectedDeployment !== 'Dedicated' ? regionLocationsColocated : regionLocationsDedicated; + const regionLookup = selectedDeployment !== 'Dedicated' + ? colocatedRegionLookup + : dedicatedRegionLookup; useEffect(function syncInstancesAndRegionsWithSelfManagedSelection() { const values = form.getValues(); @@ -257,42 +332,71 @@ 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 firstRegionId = selectedRegionPlans[0].regionId; + if (!isOrganizationRegionId(firstRegionId) && !allowedRegionIds.includes(firstRegionId)) { 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, + ]); + + useEffect(function revalidateCustomRegionsWhenLookupChanges() { + // A custom region defined inline only resolves once its refetch lands. + if (form.getValues('regionPlans').some(entry => isOrganizationRegionId(entry.regionId))) { + void form.trigger('regionPlans'); + } + }, [form, regionLookup]); 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 - ? 0 - : selectedPlan.priceUsd * regionPlan.instanceCount / 2); + : selectedRegionPlans.reduce((total, entry) => { + const region = regionLookup.get(entry.regionId); + if (!region) { + return total; + } + // Central-manager mints a block per instance pair (rounded up) × quantity for a custom region. + return total + (region.kind === 'organization' + ? selectedPlan.priceUsd * region.blocksPerUnit * (entry.quantity ?? 1) + : selectedPlan.priceUsd * region.instanceCount / 2); }, 0); const expirationMonths = selectedPlan?.planLimits?.expirationMonths; @@ -372,7 +476,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'; @@ -386,13 +490,28 @@ export function ClusterForm({ planId: plan.id, }); } - } else { + } else if (mode !== 'version') { 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; + } + // 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({ autoRenew: true, planId: plan.id, regionId: region.id, + ...(region.kind === 'organization' ? { quantity: regionPlan.quantity ?? 1 } : {}), }); } } @@ -448,7 +567,7 @@ export function ClusterForm({ onClusterSavedCallback, onStartSaving, organizationId, - regionNameToLatencyToRegion, + regionLookup, setSavedClusterState, submitEditClusterData, submitNewClusterData, @@ -550,7 +669,12 @@ export function ClusterForm({ mode={mode} partialUpgrade={partialUpgrade} regionLocations={regionLocations} + regionLookup={regionLookup} regionNameToLatencyToRegion={regionNameToLatencyToRegion} + organizationId={organizationId} + canUseCustomRegions={canUseCustomRegions} + canDefineCustomRegions={canDefineCustomRegions} + 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 05281179d..574edf77b 100644 --- a/src/features/clusters/upsert/ClusterRegions.tsx +++ b/src/features/clusters/upsert/ClusterRegions.tsx @@ -1,18 +1,23 @@ 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 { toast } from 'sonner'; import { RegionFormInputs } from './components/RegionFormInputs'; import { PremiumOnlyRegions } from './lib/calculatePremiumOnlyRegions'; import { UsageScale } from './lib/calculateUsageScale'; -import { UpsertClusterSchemaType } from './upsertClusterSchema'; +import { regionCohortKey, RegionLookup } from './lib/regionLookup'; +import { RegionPlanEntry, UpsertClusterSchemaType } from './upsertClusterSchema'; interface ClusterRegionsProps { form: UseFormReturn; regionLocations: SchemaRegion[] | undefined; + regionLookup: RegionLookup; regionNameToLatencyToRegion: Record>; premiumOnlyRegions: PremiumOnlyRegions; usageScale: UsageScale; @@ -20,11 +25,17 @@ interface ClusterRegionsProps { totalPrice: number | undefined; isEnterprise: boolean; cloudProvider: keyof SchemaCloudInstanceTypes | undefined; + organizationId: string; + canUseCustomRegions: boolean; + canDefineCustomRegions: boolean; + /** Custom regions the server already has on this cluster; a member keeps these, read-only. */ + lockedOrganizationRegionIds: string[]; } export function ClusterRegions({ form, regionLocations, + regionLookup, regionNameToLatencyToRegion, premiumOnlyRegions, usageScale, @@ -32,6 +43,10 @@ export function ClusterRegions({ totalPrice, isEnterprise, cloudProvider, + organizationId, + canUseCustomRegions, + canDefineCustomRegions, + lockedOrganizationRegionIds, }: ClusterRegionsProps) { const selectedRegionPlans = form.watch('regionPlans'); @@ -40,27 +55,48 @@ export function ClusterRegions({ name: 'regionPlans', }); - const nextAvailableRegionToAdd = useMemo(() => { - const selectedRegionNames = selectedRegionPlans.map(region => - regionNameToLatencyToRegion?.[region.regionName!]?.[region.latencyDescription!]?.region - ); + const nextAvailableRegionToAdd = useMemo(() => { 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; + }), + ); + const nextCatalogRegion = regionLocations?.find(r => !selectedCohorts.has(r.region)); + if (nextCatalogRegion) { + return { regionId: nextCatalogRegion.id }; + } + if (canUseCustomRegions) { + for (const region of regionLookup.values()) { + if ( + region.kind === 'organization' && region.active && region.instanceCount > 0 + && !selectedCohorts.has(region.id) + ) { + return { regionId: region.id, quantity: 1 }; + } + } + } + return null; + }, [canUseCustomRegions, regionLocations, regionLookup, selectedRegionPlans, totalPrice]); const onAddARegionClick = useCallback(() => { if (nextAvailableRegionToAdd) { - regionPlansFieldArray.append({ - regionName: nextAvailableRegionToAdd.region, - latencyDescription: nextAvailableRegionToAdd.latencyDescription, - }); + regionPlansFieldArray.append(nextAvailableRegionToAdd); void form.trigger(); } }, [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) => { + toast.success(`${region.name} is ready to pick under Custom regions`); + }, []); + if (!regionLocations?.length) { return (
@@ -90,27 +126,52 @@ 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} + lockedOrganizationRegionIds={lockedOrganizationRegionIds} + selectedRegionIds={selectedRegionPlans.map(entry => entry.regionId)} /> ))} - {nextAvailableRegionToAdd && ( -
- + {(nextAvailableRegionToAdd || canDefineCustomRegions) && ( +
+ {nextAvailableRegionToAdd && ( + + )} + {canDefineCustomRegions && ( + + )}
)} + {canDefineCustomRegions && ( + + )} ); } diff --git a/src/features/clusters/upsert/components/RegionFormInputs.tsx b/src/features/clusters/upsert/components/RegionFormInputs.tsx index dfc68a1f6..e9f585468 100644 --- a/src/features/clusters/upsert/components/RegionFormInputs.tsx +++ b/src/features/clusters/upsert/components/RegionFormInputs.tsx @@ -5,15 +5,32 @@ 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 { Ref, useCallback, useMemo, useState } 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 +38,108 @@ 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; + lockedOrganizationRegionIds: string[]; + /** Every row's region id, this row's included; a custom region already on another row cannot be picked twice. */ + 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]; +} + +// The form stores a region id; the name and latency selects are a view over the catalog for it. export function RegionFormInputs({ control, fieldArray, form, index, + regionLookup, regionNameToLatencyToRegion, premiumOnlyRegions, usageScale, selectedPlan, isEnterprise, cloudProvider, + canUseCustomRegions, + lockedOrganizationRegionIds, + selectedRegionIds, }: 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], ); + 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. + // Locked only when the server's plans carry it: a retried failed cluster carries the row as a draft, + // which a member must be able to swap out. + const lockedToOrganizationRegion = !!organizationSelection && !canUseCustomRegions + && lockedOrganizationRegionIds.includes(organizationSelection.id); const allowedRegionIds = selectedPlan?.allowedRegionIds; const isRegionAllowedByPlan = useCallback( @@ -72,31 +161,45 @@ 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); void form.trigger(); }, [fieldArray, form, index]); + const resourcesRegion = useMemo(() => resolved ? regionAtQuantity(resolved, entryQuantity) : undefined, [ + entryQuantity, + resolved, + ]); + return (
( + name={`regionPlans.${index}.regionId`} + render={() => ( @@ -104,17 +207,16 @@ export function RegionFormInputs({ @@ -132,51 +263,80 @@ export function RegionFormInputs({ )} /> - ( - + {organizationSelection + ? ( + <> +
+ Datacenters per unit +

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

+
+ ( + + Units + + { + field.onChange(value); + void form.trigger(); + }} + onBlur={field.onBlur} + name={field.name} + inputRef={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 && (