Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
117c95e
feat(admin): manage an organization's custom regions
DavidCockerill Oct 2, 2026
01f8d86
feat(clusters): key region plans by id and let staff deploy custom re…
DavidCockerill Oct 2, 2026
b4d4a6b
fix(clusters): reconcile drafts with the live cluster and survive a f…
DavidCockerill Oct 2, 2026
f4d4b38
fix(clusters): keep unresolved draft regions for validation and unblo…
DavidCockerill Oct 2, 2026
105896c
fix(clusters): let a version-only edit through without region plans
DavidCockerill Oct 2, 2026
270f8af
feat(admin): derive a custom region's blocks from its shape
DavidCockerill Oct 5, 2026
7c97be5
fix(clusters): refuse a custom region with no datacenters on the orga…
DavidCockerill Oct 5, 2026
16cc5c4
fix(clusters): a member retrying a failed cluster can swap out its cu…
DavidCockerill Oct 5, 2026
ecbacf4
feat(clusters): define custom regions first, then pick them per row
DavidCockerill Oct 5, 2026
d8196dc
fix(admin): name the provider a custom region must list, and explain …
DavidCockerill Oct 7, 2026
fa8e4e6
fix(clusters): defining a custom region no longer submits the cluster…
DavidCockerill Oct 7, 2026
7cc81b3
fix(clusters): let the Units box be emptied while retyping
DavidCockerill Oct 7, 2026
07f2688
refactor(admin): drop the Custom regions admin page; the org tab will…
DavidCockerill Oct 7, 2026
0d93ff7
chore(admin): restore routes.ts to stage's layout
DavidCockerill Oct 7, 2026
a6205b9
fix(clusters): an option description too long for its select ends in …
DavidCockerill Oct 7, 2026
9918e7c
fix(clusters): drop the picker hint that pointed at the removed admin…
DavidCockerill Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('admin section visibility', () => {
'Regions',
]);
expect(labels(user('fabric_readonly', ['region:read']))).toEqual(['Regions']);
expect(labels(user('fabric_readonly', ['systemStatus:write']))).toEqual(['Notifications']);
});

it('hides the section entirely from customers', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { OrganizationRegion } from '@/integrations/api/api.patch';
import { describe, expect, it } from 'vitest';
import {
NO_FALLBACK,
OrganizationRegionFormSchema,
toCreatePayload,
toFormValues,
toPatch,
} from './OrganizationRegionFormSchema';

const stored: OrganizationRegion = {
id: 'oreg-1',
organizationId: 'org-1',
name: 'EU edge',
placement: { linode: ['fr-par', 'it-mil'], gcp: [] },
fallbackGroup: 'europe',
active: true,
};

const valid = {
name: 'EU edge',
linodeDatacenters: ['fr-par'],
gcpDatacenters: [],
fallbackGroup: NO_FALLBACK,
active: true,
};

describe('OrganizationRegionFormSchema', () => {
it('accepts a region with one provider populated', () => {
expect(OrganizationRegionFormSchema.safeParse(valid).success).toBe(true);
});

it('requires at least one datacenter across providers', () => {
const result = OrganizationRegionFormSchema.safeParse({ ...valid, linodeDatacenters: [] });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['gcpDatacenters']);
}
});

it('trims and requires the name', () => {
expect(OrganizationRegionFormSchema.safeParse({ ...valid, name: ' ' }).success).toBe(false);
const parsed = OrganizationRegionFormSchema.safeParse({ ...valid, name: ' EU ' });
expect(parsed.success && parsed.data.name).toBe('EU');
});
});

describe('toFormValues', () => {
it('maps a stored row and defaults a new one', () => {
expect(toFormValues(stored)).toEqual({
name: 'EU edge',
linodeDatacenters: ['fr-par', 'it-mil'],
gcpDatacenters: [],
fallbackGroup: 'europe',
active: true,
});
expect(toFormValues(null)).toEqual({
name: '',
linodeDatacenters: [],
gcpDatacenters: [],
fallbackGroup: NO_FALLBACK,
active: true,
});
});
});

describe('toCreatePayload', () => {
it('stores the no-fallback choice as null', () => {
expect(toCreatePayload('org-1', valid)).toEqual({
organizationId: 'org-1',
name: 'EU edge',
placement: { linode: ['fr-par'], gcp: [] },
fallbackGroup: null,
active: true,
});
});
});

describe('toPatch', () => {
it('sends only the fields that changed', () => {
expect(toPatch(stored, toFormValues(stored))).toEqual({});
expect(toPatch(stored, { ...toFormValues(stored), active: false })).toEqual({ active: false });
});

it('sends the whole placement when either provider list changes', () => {
expect(toPatch(stored, { ...toFormValues(stored), gcpDatacenters: ['europe-west1'] })).toEqual({
placement: { linode: ['fr-par', 'it-mil'], gcp: ['europe-west1'] },
});
});

it('clears the fallback pool with null', () => {
expect(toPatch(stored, { ...toFormValues(stored), fallbackGroup: NO_FALLBACK })).toEqual({ fallbackGroup: null });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { OrganizationRegion, OrganizationRegionPatch, OrganizationRegionPayload } from '@/integrations/api/api.patch';
import { z } from 'zod';

/** Fields central-manager freezes once a live cluster deploys the region. */
export const FROZEN_WHILE_REFERENCED = ['name', 'linodeDatacenters', 'gcpDatacenters', 'fallbackGroup'] as const;

/** The form's "no fallback pool" choice; the API stores null for it. */
export const NO_FALLBACK = '';

// A repeated datacenter asks for another instance there.
export const OrganizationRegionFormSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(64, 'Keep the name to 64 characters'),
linodeDatacenters: z.array(z.string()),
gcpDatacenters: z.array(z.string()),
fallbackGroup: z.string(),
active: z.boolean(),
}).superRefine((values, ctx) => {
if (!values.linodeDatacenters.length && !values.gcpDatacenters.length) {
ctx.addIssue({
code: 'custom',
path: ['gcpDatacenters'],
message: 'Add at least one datacenter under Linode or GCP',
});
}
});

export type OrganizationRegionFormValues = z.infer<typeof OrganizationRegionFormSchema>;

export function toFormValues(region?: OrganizationRegion | null): OrganizationRegionFormValues {
return {
name: region?.name ?? '',
linodeDatacenters: region?.placement?.linode ?? [],
gcpDatacenters: region?.placement?.gcp ?? [],
fallbackGroup: region?.fallbackGroup ?? NO_FALLBACK,
active: region?.active ?? true,
};
}

export function toCreatePayload(
organizationId: string,
values: OrganizationRegionFormValues,
): OrganizationRegionPayload {
return {
organizationId,
name: values.name,
placement: { linode: values.linodeDatacenters, gcp: values.gcpDatacenters },
fallbackGroup: values.fallbackGroup === NO_FALLBACK ? null : values.fallbackGroup,
active: values.active,
};
}

export function toPatch(region: OrganizationRegion, values: OrganizationRegionFormValues): OrganizationRegionPatch {
const stored = toFormValues(region);
const patch: OrganizationRegionPatch = {};
if (values.name !== stored.name) {
patch.name = values.name;
}
const sameList = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]);
if (
!sameList(values.linodeDatacenters, stored.linodeDatacenters)
|| !sameList(values.gcpDatacenters, stored.gcpDatacenters)
) {
patch.placement = { linode: values.linodeDatacenters, gcp: values.gcpDatacenters };
}
if (values.fallbackGroup !== stored.fallbackGroup) {
patch.fallbackGroup = values.fallbackGroup === NO_FALLBACK ? null : values.fallbackGroup;
}
if (values.active !== stored.active) {
patch.active = values.active;
}
return patch;
}
Loading
Loading