From 117c95ed9bd20ac6a1d644e1ef12fd76a0af3ba3 Mon Sep 17 00:00:00 2001 From: David C Date: Fri, 2 Oct 2026 16:56:04 -0400 Subject: [PATCH 01/16] 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 --- .../__tests__/adminShellVisibility.test.ts | 13 +- src/features/admin/components/AdminShell.tsx | 3 +- .../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 + src/features/admin/routes.ts | 12 +- .../clusters/upsert/lib/regionLookup.test.ts | 103 ++++++ .../clusters/upsert/lib/regionLookup.ts | 109 ++++++ src/integrations/api/api.patch.d.ts | 54 ++- 14 files changed, 1035 insertions(+), 9 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/__tests__/adminShellVisibility.test.ts b/src/features/admin/__tests__/adminShellVisibility.test.ts index 6a2df4813..2f7810246 100644 --- a/src/features/admin/__tests__/adminShellVisibility.test.ts +++ b/src/features/admin/__tests__/adminShellVisibility.test.ts @@ -12,15 +12,18 @@ const labels = (u: User | null) => visibleAdminItems(u).map((item) => item.label describe('admin section visibility', () => { it('shows every page to a holder of all three page permissions', () => { const admin = user('fabric_admin', ['systemStatus:write', 'region:read', 'apiToken:create']); - expect(labels(admin)).toEqual(['Notifications', 'Regions', 'API Token']); + expect(labels(admin)).toEqual(['Notifications', 'Regions', 'Custom regions', 'API Token']); }); it('shows only the pages a narrower role holds', () => { expect(labels(user('fabric_support', ['systemStatus:write', 'region:read']))).toEqual([ 'Notifications', 'Regions', + 'Custom regions', ]); - expect(labels(user('fabric_readonly', ['region:read']))).toEqual(['Regions']); + // Both region pages ride the same read permission. + expect(labels(user('fabric_readonly', ['region:read']))).toEqual(['Regions', 'Custom regions']); + expect(labels(user('fabric_readonly', ['systemStatus:write']))).toEqual(['Notifications']); }); it('hides the section entirely from customers', () => { @@ -32,12 +35,12 @@ describe('admin section visibility', () => { // 403s, so the API Token page stays hidden even though it holds the permission. it('never shows the API Token page to super_user', () => { const withField = user('super_user', ['systemStatus:write', 'region:read', 'apiToken:create']); - expect(labels(withField)).toEqual(['Notifications', 'Regions']); + expect(labels(withField)).toEqual(['Notifications', 'Regions', 'Custom regions']); // Legacy API without staffPermissions: same carve-out via the fallback. - expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions']); + expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions', 'Custom regions']); }); it('keeps the whole section for a legacy-API fabric_admin', () => { - expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'API Token']); + expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Custom regions', 'API Token']); }); }); diff --git a/src/features/admin/components/AdminShell.tsx b/src/features/admin/components/AdminShell.tsx index 8b3547f68..21852f900 100644 --- a/src/features/admin/components/AdminShell.tsx +++ b/src/features/admin/components/AdminShell.tsx @@ -2,7 +2,7 @@ import { SubNavItem, SubNavRail } from '@/components/SubNavRail'; import { hasStaffPermission, useCloudAuth } from '@/hooks/useAuth'; import { LocalUser, StaffPermission, User } from '@/integrations/api/api.patch'; import { Navigate, Outlet, useLocation } from '@tanstack/react-router'; -import { BellIcon, GlobeIcon, KeyRoundIcon } from 'lucide-react'; +import { BellIcon, GlobeIcon, KeyRoundIcon, MapPinnedIcon } from 'lucide-react'; /** * Shell for the Admin section: a responsive sub-nav rail (so future admin @@ -16,6 +16,7 @@ import { BellIcon, GlobeIcon, KeyRoundIcon } from 'lucide-react'; const items: Array = [ { to: '/admin/notifications', label: 'Notifications', icon: BellIcon, permission: 'systemStatus:write' }, { to: '/admin/regions', label: 'Regions', icon: GlobeIcon, permission: 'region:read' }, + { to: '/admin/custom-regions', label: 'Custom regions', icon: MapPinnedIcon, permission: 'region:read' }, // Minting a token requires the Google SSO session only staff sign-ins have; // super_user may password-login, so the mint would 403 for it. { 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/admin/routes.ts b/src/features/admin/routes.ts index c73f7e50c..4905368bd 100644 --- a/src/features/admin/routes.ts +++ b/src/features/admin/routes.ts @@ -34,6 +34,16 @@ const regionsRoute = createRoute({ component: lazyRouteComponent(async () => import('@/features/admin/regions/index'), 'RegionsIndex'), }); +const organizationRegionsRoute = createRoute({ + getParentRoute: () => adminLayoutRoute, + path: 'custom-regions', + head: () => ({ meta: [{ title: 'Custom regions — Harper Fabric' }] }), + component: lazyRouteComponent( + async () => import('@/features/admin/organizationRegions/index'), + 'OrganizationRegionsIndex', + ), +}); + const apiTokenRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: 'api-token', @@ -42,4 +52,4 @@ const apiTokenRoute = createRoute({ }); // Parent: adminLayoutRoute (keep in lockstep with rootRouteTree's addChildren). -export const adminRoutes = [adminIndexRoute, notificationsAdminRoute, regionsRoute, apiTokenRoute]; +export const adminRoutes = [adminIndexRoute, notificationsAdminRoute, regionsRoute, organizationRegionsRoute, apiTokenRoute]; 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 84cac79f2..54e89fae3 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'; @@ -120,6 +122,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 @@ -246,11 +294,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 { @@ -259,7 +308,8 @@ export interface ClusterSyncSummary { progress?: number; } -export interface ClusterUpsert extends SchemaClusterUpsert { +export interface ClusterUpsert extends Omit { + regionPlans: ClusterUpsertRegionPlan[]; skipGtmWait?: boolean; version?: string; } From 01f8d8608069fc943806c33340778323c81186c2 Mon Sep 17 00:00:00 2001 From: David C Date: Fri, 2 Oct 2026 16:56:04 -0400 Subject: [PATCH 02/16] 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 58e1bd0c5..2aab6dc31 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,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; @@ -53,7 +57,10 @@ export function ClusterDetails({ mode, partialUpgrade, regionLocations, + regionLookup, regionNameToLatencyToRegion, + organizationId, + canUseCustomRegions, selectedDeployment, selectedPerformance, selectedPlan, @@ -241,7 +248,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(); @@ -124,53 +143,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({ @@ -200,6 +257,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 +317,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; @@ -372,7 +442,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'; @@ -388,11 +458,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 } : {}), }); } } @@ -448,7 +525,7 @@ export function ClusterForm({ onClusterSavedCallback, onStartSaving, organizationId, - regionNameToLatencyToRegion, + regionLookup, setSavedClusterState, submitEditClusterData, submitNewClusterData, @@ -550,7 +627,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/admin/routes.ts b/src/features/admin/routes.ts index 4905368bd..69555e255 100644 --- a/src/features/admin/routes.ts +++ b/src/features/admin/routes.ts @@ -52,4 +52,10 @@ const apiTokenRoute = createRoute({ }); // Parent: adminLayoutRoute (keep in lockstep with rootRouteTree's addChildren). -export const adminRoutes = [adminIndexRoute, notificationsAdminRoute, regionsRoute, organizationRegionsRoute, apiTokenRoute]; +export const adminRoutes = [ + adminIndexRoute, + notificationsAdminRoute, + regionsRoute, + organizationRegionsRoute, + apiTokenRoute, +]; diff --git a/src/features/clusters/upsert/ClusterForm.tsx b/src/features/clusters/upsert/ClusterForm.tsx index daa9f9028..13ae47784 100644 --- a/src/features/clusters/upsert/ClusterForm.tsx +++ b/src/features/clusters/upsert/ClusterForm.tsx @@ -367,7 +367,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. @@ -493,7 +501,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; } @@ -664,7 +674,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 d8196dcc79c5ac0b0ae6f49e579bd5ca79688331 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 10:23:03 -0400 Subject: [PATCH 10/16] 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 fa8e4e6ab6b6896d07484b1c7f24de5800192b44 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 10:43:33 -0400 Subject: [PATCH 11/16] 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/16] 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 07f2688b8d285e598ec3b9711aa119187df73f03 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 11:56:35 -0400 Subject: [PATCH 13/16] 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 --- .../__tests__/adminShellVisibility.test.ts | 12 +- src/features/admin/components/AdminShell.tsx | 3 +- .../admin/organizationRegions/index.tsx | 144 ------------------ src/features/admin/routes.ts | 11 -- 4 files changed, 6 insertions(+), 164 deletions(-) delete mode 100644 src/features/admin/organizationRegions/index.tsx diff --git a/src/features/admin/__tests__/adminShellVisibility.test.ts b/src/features/admin/__tests__/adminShellVisibility.test.ts index 2f7810246..382691afd 100644 --- a/src/features/admin/__tests__/adminShellVisibility.test.ts +++ b/src/features/admin/__tests__/adminShellVisibility.test.ts @@ -12,17 +12,15 @@ const labels = (u: User | null) => visibleAdminItems(u).map((item) => item.label describe('admin section visibility', () => { it('shows every page to a holder of all three page permissions', () => { const admin = user('fabric_admin', ['systemStatus:write', 'region:read', 'apiToken:create']); - expect(labels(admin)).toEqual(['Notifications', 'Regions', 'Custom regions', 'API Token']); + expect(labels(admin)).toEqual(['Notifications', 'Regions', 'API Token']); }); it('shows only the pages a narrower role holds', () => { expect(labels(user('fabric_support', ['systemStatus:write', 'region:read']))).toEqual([ 'Notifications', 'Regions', - 'Custom regions', ]); - // Both region pages ride the same read permission. - expect(labels(user('fabric_readonly', ['region:read']))).toEqual(['Regions', 'Custom regions']); + expect(labels(user('fabric_readonly', ['region:read']))).toEqual(['Regions']); expect(labels(user('fabric_readonly', ['systemStatus:write']))).toEqual(['Notifications']); }); @@ -35,12 +33,12 @@ describe('admin section visibility', () => { // 403s, so the API Token page stays hidden even though it holds the permission. it('never shows the API Token page to super_user', () => { const withField = user('super_user', ['systemStatus:write', 'region:read', 'apiToken:create']); - expect(labels(withField)).toEqual(['Notifications', 'Regions', 'Custom regions']); + expect(labels(withField)).toEqual(['Notifications', 'Regions']); // Legacy API without staffPermissions: same carve-out via the fallback. - expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions', 'Custom regions']); + expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions']); }); it('keeps the whole section for a legacy-API fabric_admin', () => { - expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Custom regions', 'API Token']); + expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'API Token']); }); }); diff --git a/src/features/admin/components/AdminShell.tsx b/src/features/admin/components/AdminShell.tsx index 21852f900..8b3547f68 100644 --- a/src/features/admin/components/AdminShell.tsx +++ b/src/features/admin/components/AdminShell.tsx @@ -2,7 +2,7 @@ import { SubNavItem, SubNavRail } from '@/components/SubNavRail'; import { hasStaffPermission, useCloudAuth } from '@/hooks/useAuth'; import { LocalUser, StaffPermission, User } from '@/integrations/api/api.patch'; import { Navigate, Outlet, useLocation } from '@tanstack/react-router'; -import { BellIcon, GlobeIcon, KeyRoundIcon, MapPinnedIcon } from 'lucide-react'; +import { BellIcon, GlobeIcon, KeyRoundIcon } from 'lucide-react'; /** * Shell for the Admin section: a responsive sub-nav rail (so future admin @@ -16,7 +16,6 @@ import { BellIcon, GlobeIcon, KeyRoundIcon, MapPinnedIcon } from 'lucide-react'; const items: Array = [ { to: '/admin/notifications', label: 'Notifications', icon: BellIcon, permission: 'systemStatus:write' }, { to: '/admin/regions', label: 'Regions', icon: GlobeIcon, permission: 'region:read' }, - { to: '/admin/custom-regions', label: 'Custom regions', icon: MapPinnedIcon, permission: 'region:read' }, // Minting a token requires the Google SSO session only staff sign-ins have; // super_user may password-login, so the mint would 403 for it. { 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 && ( - - )} -
- ); -} diff --git a/src/features/admin/routes.ts b/src/features/admin/routes.ts index 69555e255..092ba32e4 100644 --- a/src/features/admin/routes.ts +++ b/src/features/admin/routes.ts @@ -34,16 +34,6 @@ const regionsRoute = createRoute({ component: lazyRouteComponent(async () => import('@/features/admin/regions/index'), 'RegionsIndex'), }); -const organizationRegionsRoute = createRoute({ - getParentRoute: () => adminLayoutRoute, - path: 'custom-regions', - head: () => ({ meta: [{ title: 'Custom regions — Harper Fabric' }] }), - component: lazyRouteComponent( - async () => import('@/features/admin/organizationRegions/index'), - 'OrganizationRegionsIndex', - ), -}); - const apiTokenRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: 'api-token', @@ -56,6 +46,5 @@ export const adminRoutes = [ adminIndexRoute, notificationsAdminRoute, regionsRoute, - organizationRegionsRoute, apiTokenRoute, ]; From 0d93ff7579654a31dc9ce09a25ca6b6c8e926786 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 11:58:14 -0400 Subject: [PATCH 14/16] chore(admin): restore routes.ts to stage's layout Co-Authored-By: Claude Fable 5.1 --- src/features/admin/routes.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/features/admin/routes.ts b/src/features/admin/routes.ts index 092ba32e4..c73f7e50c 100644 --- a/src/features/admin/routes.ts +++ b/src/features/admin/routes.ts @@ -42,9 +42,4 @@ const apiTokenRoute = createRoute({ }); // Parent: adminLayoutRoute (keep in lockstep with rootRouteTree's addChildren). -export const adminRoutes = [ - adminIndexRoute, - notificationsAdminRoute, - regionsRoute, - apiTokenRoute, -]; +export const adminRoutes = [adminIndexRoute, notificationsAdminRoute, regionsRoute, apiTokenRoute]; From a6205b91fd98a041c2b835aa3ecb50d5591f384e Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 12:59:05 -0400 Subject: [PATCH 15/16] 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 9918e7c5326d580f5869992b7fb64b9a50524bbc Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 7 Oct 2026 12:54:24 -0400 Subject: [PATCH 16/16] 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 && (