Repository navigation
feat(clusters): org-owned custom regions — picker group and units on the cluster form - #1780
DavidCockerill wants to merge 16 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces organization-owned custom regions, allowing staff to define and manage custom datacenter placements and integrate them into the cluster creation and update flows. It adds dedicated administration routes, schemas, and modals for custom regions, alongside utilities to resolve region plans and migrate legacy drafts. Feedback on the changes highlights a correction in Zod's schema validation where passing an error option directly to z.number() is unsupported, and suggests safer handling of controlled numeric inputs in React to prevent uncontrolled input warnings when clearing values.
| 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', | ||
| ), |
There was a problem hiding this comment.
In Zod, passing { error: '...' } to primitive schemas like z.number() is not supported and will be ignored. The correct options to customize the error messages are invalid_type_error and required_error. Without these, Zod will fall back to default error messages (e.g., "Expected number, received undefined") when the input is empty or invalid.
blocksPerUnit: z.number({
invalid_type_error: 'Enter a whole number',
required_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',
),| value={Number.isFinite(field.value) ? field.value : ''} | ||
| onChange={(e) => field.onChange(e.target.valueAsNumber)} |
There was a problem hiding this comment.
When rendering controlled numeric inputs in React, use Number.isFinite(field.value) ? field.value : '' to safely handle NaN, undefined, null, and Infinity in a single predicate, preventing React's uncontrolled input warnings. Additionally, map NaN to undefined when the input is cleared to match the form schema expectations.
value={Number.isFinite(field.value) ? field.value : ''}
onChange={(e) => {
const val = e.target.valueAsNumber;
field.onChange(Number.isNaN(val) ? undefined : val);
}}
References
- When rendering controlled numeric inputs in React, use
Number.isFinite(field.value) ? field.value : ''to safely handleNaN,undefined,null, andInfinityin a single predicate, preventing React's uncontrolled input warnings.
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 <noreply@anthropic.com>
…gions
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 <noreply@anthropic.com>
…ailed custom-region fetch Addresses the first cross-model review round of #1778: - A restored draft no longer bypasses the unresolved-region refusal: the check keys on the server's plans, so a custom region deleted while the user was at billing still blocks the edit instead of being dropped from the next save. - The custom regions a member may keep come from the server's plans, not from form defaults a draft could have seeded. - A failed /OrganizationRegion fetch (anything but the rollout 404) no longer strands every cluster form on "Loading…": catalog-only forms open, and an edit that needs a custom region is refused with a retry message. - Price for a custom region follows what central-manager mints (blocksPerUnit × units), not the shape's instance count. - "Add Additional Region Usage" offers an existing custom region once the catalog families are exhausted; "Define custom region" also requires region:write, which the modal's save needs. - A region defined inline re-validates once its refetch lands, so the interim "no longer available" error clears itself. - The fallback-coverage check in the custom-region modal only runs when the shape or pool is actually being written, so toggling `active` on a frozen region never waits on the Location catalog. - The resources panel gets a memoized region view; narrating comments trimmed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ck version edits Second review round of #1778: - A saved draft keeps every id-keyed region entry, resolved or not, so a custom region the lookup lacks (fetch failure on the billing return, deleted row) is reported on its row by validation instead of vanishing from the draft and being replaced by a catalog default. - The unresolved-region refusal no longer applies to version-only edits, which never send region plans. - The allowed-region auto-select skips any custom-region id, resolved or pending, so an inline-defined region is not overwritten in its refetch window. - The custom-region modal skips its fallback-coverage check while the Location catalog is absent (central-manager validates regardless), and its mutations opt out of the global error toast since failures land on the form. - Comment corrections and trims. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A version edit never submits regions, so it no longer carries the empty region placeholder the schema rejects, and submit skips region resolution in that mode. Remaining narrating comments trimmed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A purchased block covers a pair of instances — every catalog row's purchasedBlockMultiplier is instanceCount / 2 — so central-manager now derives a custom region's blocks per unit from its datacenter list (rounded up) instead of storing a staff-entered number. The admin form and table drop the field, the API overlay types drop it, and the cluster form prices a custom region by ⌈datacenters / 2⌉ × units, which is the catalog's own rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nization's provider Fourth review round of #1778: such a region (placed only on the other provider) is hidden from the picker and, if already on the form, refused by validation rather than submitted at zero instances and zero blocks. A version-only edit skips region validation entirely, so a stale draft cannot block it. The frozen-region note and the type doc no longer mention blocks per unit; two exports with one consumer are internal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stom region Fifth review round of #1778: "Try again" carries a failed cluster's plans as a draft with no route cluster, so a staff-placed custom region was locked on the row yet refused by validation. The row is now locked only when the server's plans carry it. Payment review submits without re-validating, so submit also refuses a custom region whose shape lost every datacenter on the organization's provider while the user was away. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
611ac90 to
16cc5c4
Compare
David: defining a custom region from the cluster form should not place it on the deployment rows. It now only lands in the picker's Custom regions group (with a toast), so staff can define several and then choose which rows deploy them. A custom region can be on a cluster once — units say how many copies — so the picker disables one that is already on another row instead of leaving validation to refuse the duplicate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… host it 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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
Studio side of org-owned custom regions: a cluster form that keys each region plan by id (
{ regionId, quantity? }) so a custom region — which has neither a catalog name nor a latency tier — can be picked, sized in units and submitted alongside catalog tiers. Staff with the cluster permission see the organization's custom regions in the picker and can define one inline (the organization admin tab that lists and edits them is a separate PR); a member keeps a custom region staff already placed but cannot add or swap one. Closes #1778 (PR 2 of the plan in central-manager#889; pairs with central-manager#892 — Org-owned custom regions with deploy-time quantity: a central-manager without the resource answers 404, which the form reads as "no custom regions", so this can deploy first).For the human reviewer
{ regionName, latencyDescription }to{ regionId, quantity? }, with the name and latency selects becoming a view over an id-keyed lookup. The planning review rejected my first draft (a custom region as a virtual catalog row, keyed by name) because a custom region's name may legitimately equal a catalog family or Location tag; keying by id is what central-manager stores anyway. Cost: every consumer of the entry (validation, price, resources panel, defaults, drafts, submit) was touched. Reversible only by reverting the form.RegionFormInputs.tsx) — only while the server's plans carry it, so a member retrying a failed cluster (whose plans arrive as a draft, not a route cluster) can swap the row out — and validation refuses a custom region the server's plans don't already carry. The alternative — let the member drop it — hands a customer control over a placement an FDE designed and sized. Client-side only; central-manager's staff gate is the real boundary. One-line change if you want members to be able to remove one./OrganizationRegionfetch itself failed, with retry copy) instead. Round 1 found a restored billing-redirect draft bypassed this; the check now keys on the server's plans, never the draft. The cost is that staff cannot edit such a cluster at all until the row is restored; the alternative (pass unknown plans through untouched) needs central-manager to accept plans it cannot resolve, which it does not.oreg-id, so the picker disables a custom region already on another row ("change its units") rather than letting validation refuse a duplicate. Defining one from the form never edits the rows (decided 2026-10-05): it lands in the picker, so staff can define several and then choose.allowedRegionIdsand one-region rules on the client. (A custom region with no datacenters on the organization's provider is hidden from the picker and refused by validation and at submit, since it would deploy nothing.) (A custom region with no datacenters on the organization's provider is hidden from the picker and refused by validation and at submit, since it would deploy nothing.) Those rules describe the catalog (stringsShareAPrefixon catalog ids); central-manager has noallowedRegionIdsenforcement and decides what a custom region may do. Consequence flagged in review: a staff user can add a custom region to a free plan without the client objecting. Kept: staff-only, and the entitlement policy belongs server-side. Say the word and the client can apply the one-region rule to custom regions too.organization.channel === 'Akamai' → linode, otherwise GCP — the same rule the form already uses forcloudInstanceTypes. A custom region's placement is per provider, and the shape shown (datacenters per unit, instance count, price) follows this choice. If central-manager ever derives the provider differently, the displayed shape is wrong; cheap to change once the server exposes it.priceUsd × ⌈datacenters per unit / 2⌉ × units, because that is what central-manager mints: a purchased block per instance pair, rounded up — the catalog's ownpurchasedBlockMultiplier = instanceCount / 2rule. Staff never enter a block count (decided 2026-10-05: the first cut had ablocksPerUnitfield, since removed from the admin form, table, types and payloads; central-manager#892 derives it in its resolver). The catalog formula (priceUsd × instanceCount / 2) is unchanged./admin/custom-regions; the list-and-edit surface moves to the organization's admin tab in a separate PR. The modal, queries and form schema stay undersrc/features/admin/organizationRegions/for the cluster form's inline define and for that tab to reuse.Product and architecture tour
One region entry, two tables
How does the cluster form hold a catalog tier and a custom region in the same list?
{ regionName, latencyDescription }; the catalog row (and its id) was looked up at submit.{ regionId, quantity? };buildRegionLookupresolves catalog rows and the organization's custom regions into one id-keyed map that validation, price, the resources panel, display and submit all read.Example: a custom region with two units
Staff pick
EU edge(shapefr-par ×2 · it-mil, so ⌈3 / 2⌉ = 2 blocks per unit) on a Medium plan and set units to 2. The row shows "6 instances", the resources panel scales to the quantity, and the price line addspriceUsd × 2 × 2.Outcome: the create payload carries
{ planId, regionId: 'oreg-…', quantity: 2, autoRenew: true }next to any catalog entries, which carry noquantity.Who may do what
A member works the catalog as before. Staff holding the matching cluster permission additionally see the organization's active custom regions in the picker, set units, and scale on the same id; with
region:writeas well they can define a new one inline, which is two requests (the row, then the cluster). The table after the tour lists the matrix.Editing never loses a region
What happens when a stored region cannot be resolved?
Opening the edit form
/OrganizationRegion/?organizationId=(a 404 reads as none; any other failure reads as none too, but is remembered).Defining a custom region from the cluster form
The modal saves the row and invalidates the list; the new region then appears in the picker's Custom regions group and staff choose which rows deploy it — defining never edits the rows, so several can be defined before any is picked.
sequenceDiagram participant Staff participant Form as Cluster form participant Modal participant CM as central-manager Staff->>Form: Define custom region Form->>Modal: open Modal->>CM: POST OrganizationRegion CM-->>Modal: oreg-id Modal->>Form: onSaved(region) Form->>CM: GET OrganizationRegion list CM-->>Form: rows incl. new id Form->>Staff: listed under Custom regions Staff->>Form: pick it on a row, set unitsPermissions at a glance
cluster:create/cluster:updateregion:writeas wellChanges
Types and API.
src/integrations/api/api.patch.d.tsoverlaysOrganizationRegion(+ payload/patch shapes) andquantityon both the read (RegionPlan) and write (ClusterUpsertRegionPlan) plan shapes, since regeneratingapi.gen.d.tswas rejected earlier (it rewrites 10k lines).src/features/admin/organizationRegions/queries/getOrganizationRegions.tslists an organization's rows (404 →[]for the rollout) and reads one by id with its referencing clusters;mutations/useOrganizationRegionMutations.tscreates and patches changed fields only, because central-manager 409s any write to a frozen field while a live cluster references the row.Custom-region module (no page of its own — see the ledger).
components/OrganizationRegionFormModal.tsxis the create/edit form (per-provider datacenter multi-selects that allow repeats, with a per-datacenter count summary; fallback pool that must cover every listed datacenter, checked only when the shape or pool is being written;active), with the frozen fields disabled once the by-id read shows live clusters.OrganizationRegionFormSchema.tsholds the zod schema and thetoFormValues/toCreatePayload/toPatchmapping.regions/components/DatacenterCountSummary.tsxis shared with the catalog region form, whichregions/components/RegionFormModal.tsxnow renders under both of its pickers.Cluster form.
upsertClusterSchema.tsdefinesRegionPlanEntrySchema(regionId, optional integerquantity1–50).lib/regionLookup.tsresolves catalog tiers and custom regions into oneResolvedRegionkeyed by id (buildRegionLookup,regionAtQuantityfor the resources panel,regionCohortKeymatching central-manager's grouping,describeShape).lib/regionPlanDefaults.tsmaps a cluster's stored plans to entries and reports unresolved ids rather than dropping them, and migrates pre-id drafts through the catalog;lib/calculateDefaultDeploymentPerformanceAndRegionPlans.tspicks the default entry by catalog id.upsert/index.tsxfetches the organization's custom regions, builds defaults (refusing on unresolved ids, with the failed-fetch variant), and passes the staff gates (cluster:create|updateto use, plusregion:writeto define).ClusterForm.tsxvalidates by id (cohort duplicates, member gate against the server's plans, units required, two-instance floor for custom regions, catalog plan restrictions), prices by blocks for custom regions, carries a catalog choice across a deployment switch by name and latency tier, re-validates when the lookup changes, and submitsquantityonly for custom regions;ClusterDetails.tsxthreads the lookup, organization id and staff gates down to the regions block.ClusterRegions.tsxoffers the next catalog family — or, once those are exhausted, an existing custom region — and hosts the inline Define custom region modal.components/RegionFormInputs.tsxrenders the region select (catalog group + custom group) and either the latency select or the units field with its instance count.Select descriptions (folded in from the pricing epic).
fields/ClusterDeploymentDescription.tsxandfields/ClusterPerformanceDescription.tsxtruncate an option's description with an ellipsis instead of running it under the chevron — the same change asf1b45be6onepic/pricing-updates, cherry-picked so it reachesstage(a patch-identical commit, so the epic's next rebase drops its copy).Cluster list.
ClustersList.tsxmerges custom-region names into the region-name map the list filter uses.Verification
npx tsc -p tsconfig.app.json --noEmitclean;pnpm lintclean;pnpm formatapplied;pnpm test388 files / 3600 tests pass (11 skipped, baseline). New or extended tests:lib/regionLookup.test.ts(id prefix, provider shape, lookup over both tables, cohort keys, quantity scaling, shape description),lib/regionPlanDefaults.test.ts(stored plans → entries incl. default units, unresolved ids reported, self-hosted plans skipped, legacy draft migration),upsertClusterSchema.test.ts(id required, quantity bounds),organizationRegions/OrganizationRegionFormSchema.test.ts(schema bounds, form/payload/patch mapping incl. changed-fields-only and null fallback),admin/__tests__/adminShellVisibility.test.ts(one more narrow-role case),ClustersList.test.tsx(custom-region name in the filter).E2E (anon lane, mocked central-manager, local vite):
tests/cluster-form.anon.spec.ts— all 7 tests ran clean under a single worker (paid creation through billing with theregionPlans: [{ planId, regionId: 'us-1', autoRenew }]payload, partial version upgrade, light/dark layouts at 1440/1024/390, self-hosted creation, hosted edit submitting only capacity, enterprise payment review); the runner hung at teardown on this machine both times (after the last test, no summary line), so the per-test lines and screenshots are the evidence. The spec's catch-all route 404s/OrganizationRegion/, which is the rollout path.Live, on the local Fabric rig with central-manager from #892: the built SPA from this branch is served by the rig CM and its bundle carries the new form code;
GET /OrganizationRegion/?organizationId=…answers 200[]through the Studio client. The signed-in UI walk (picker group, units field, inline define) was not driven by me — Studio sign-in on the rig needs a human session — so it is owed before merge.🤖 Generated with Claude Code
Related PRs: #1776 overlaps
Complexity: complicated
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-grok(not-installed); declined=cursor-composer,cursor-kimi,cursor-muse; rounds=5; full=1 @ 9918e7c
Review-Attention: deep ~30m (critical: OrganizationRegionFormSchema.ts, upsertClusterSchema.ts) @ 9918e7c