-
- >
-
-
- {domain.clusterId
- ? 'To delete this domain, please unbind it from all clusters first.'
- : 'Remember to clean up your DNS records!'}
-
-
+ <>
+
{domain.domain}
+
+ {/* A disabled button takes no pointer events or focus, so a bound domain's reason hangs on a wrapper. */}
+
+ {domain.clusterId ?
{removeButton}
: removeButton}
+
+
+ {domain.clusterId
+ ? 'To delete this domain, please unbind it from all clusters first.'
+ : 'Remember to clean up your DNS records!'}
+
+
+ >
);
},
}),
@@ -155,7 +141,7 @@ export function useDataTableColumns(
variant="destructiveGhost"
className="text-muted-foreground text-xs"
disabled={isPending}
- onClick={() => unbindDomainById(domain.id)}
+ onClick={() => onUnbindClick(domain.id)}
>
Unbind Domain
@@ -190,7 +176,7 @@ export function useDataTableColumns(
], [
cluster,
deleteDomainById,
- unbindDomainById,
+ onUnbindClick,
isPending,
challengeCertificates,
isGeneratingCert,
diff --git a/src/features/clusters/hooks/useUpdateCluster.ts b/src/features/clusters/hooks/useUpdateCluster.ts
index 4c8e1436f..12587f079 100644
--- a/src/features/clusters/hooks/useUpdateCluster.ts
+++ b/src/features/clusters/hooks/useUpdateCluster.ts
@@ -21,5 +21,9 @@ async function onEditClusterSubmit(
export function useEditClusterMutation() {
return useMutation({
mutationFn: (clusterInfo) => onEditClusterSubmit(clusterInfo),
+ // ClusterForm shows the failure beside its submit button, so the fading toast is off. Reported
+ // here rather than in the form's `onError`, which React Query skips once the form unmounts.
+ meta: { skipGlobalErrorToast: true },
+ onError: (error) => console.error(error),
});
}
diff --git a/src/features/clusters/mutations/describeDomainChangeRefusal.test.ts b/src/features/clusters/mutations/describeDomainChangeRefusal.test.ts
new file mode 100644
index 000000000..fd341e49b
--- /dev/null
+++ b/src/features/clusters/mutations/describeDomainChangeRefusal.test.ts
@@ -0,0 +1,137 @@
+import { Cluster, SchemaOrganizationDomain } from '@/integrations/api/api.patch';
+import { describe, expect, it } from 'vitest';
+import { describeClusterNotChangeable, describeUnsendableDomainChange } from './describeDomainChangeRefusal';
+
+function clusterWith(fields: Partial = {}): Cluster {
+ return {
+ id: 'clu-1',
+ organizationId: 'org-1',
+ status: 'RUNNING',
+ plans: [{ planId: 'standard' }],
+ ...fields,
+ } as Cluster;
+}
+
+function instance(status: string) {
+ return { id: 'ins-1', instanceFqdn: 'node-1.example.com', status } as NonNullable[number];
+}
+
+describe('describeClusterNotChangeable', () => {
+ it('lets a running cluster whose instances are all running or gone change', () => {
+ expect(describeClusterNotChangeable(clusterWith())).toBeUndefined();
+ expect(
+ describeClusterNotChangeable(
+ clusterWith({ instances: [instance('RUNNING'), instance('TERMINATED'), instance('REMOVED')] }),
+ ),
+ ).toBeUndefined();
+ });
+
+ it.each(['UPDATING', 'STOPPED', 'FAILED', 'PROVISIONING', undefined])('refuses a cluster that is %s', (status) => {
+ expect(describeClusterNotChangeable(clusterWith({ status }))?.needsSupport).toBe(false);
+ });
+
+ // Central manager's TRANSITIONING_INSTANCE_STATES, as of central-manager 38449f3f.
+ it.each([
+ 'PROVISIONING',
+ 'PENDING_UPGRADE',
+ 'DRAINING',
+ 'UPDATING',
+ 'UPDATED',
+ 'CLONE_PENDING',
+ 'CLONE_READY',
+ 'CLONING',
+ 'UPDATING_HDB_NODES',
+ 'GENERATE_TOKEN',
+ 'TOKEN_GENERATED',
+ 'STOPPING',
+ 'STOPPED',
+ 'STARTING',
+ 'RESTARTING',
+ ])('refuses while an instance is %s, naming it', (status) => {
+ const refusal = describeClusterNotChangeable(clusterWith({ instances: [instance(status)] }));
+ expect(refusal).toEqual({
+ message: expect.stringContaining(`node-1.example.com is ${status}`),
+ needsSupport: false,
+ });
+ });
+
+ it('refuses while an instance has failed, and says only support can clear it', () => {
+ expect(describeClusterNotChangeable(clusterWith({ instances: [instance('FAILED')] }))).toEqual({
+ message: expect.stringContaining('node-1.example.com has failed'),
+ needsSupport: true,
+ });
+ });
+
+ it('leaves a status it does not know to central manager', () => {
+ expect(describeClusterNotChangeable(clusterWith({ instances: [instance('ERROR')] }))).toBeUndefined();
+ expect(describeClusterNotChangeable(clusterWith({ instances: [instance('SOMETHING_NEW')] }))).toBeUndefined();
+ });
+});
+
+describe('describeUnsendableDomainChange', () => {
+ const bound = {
+ id: 'dom-a',
+ domain: 'a.example.com',
+ status: 'ACTIVE',
+ clusterId: 'clu-1',
+ } as SchemaOrganizationDomain;
+ const free = { id: 'dom-c', domain: 'c.example.com', status: 'ACTIVE' } as SchemaOrganizationDomain;
+ const held = clusterWith({ domainIds: ['dom-a'] });
+
+ it('lets a change through when every domain it touches is acceptable', () => {
+ expect(describeUnsendableDomainChange(held, { add: ['dom-c'], remove: undefined }, [bound, free])).toBeUndefined();
+ expect(describeUnsendableDomainChange(held, { add: [], remove: 'dom-a' }, [bound, free])).toBeUndefined();
+ });
+
+ it('lets a bind adopt a domain already bound here that the cluster does not list', () => {
+ expect(
+ describeUnsendableDomainChange(clusterWith({ domainIds: [] }), { add: ['dom-a'], remove: undefined }, [bound]),
+ ).toBeUndefined();
+ });
+
+ it('refuses any change to a self-managed cluster', () => {
+ const selfManaged = clusterWith({ domainIds: ['dom-a'], plans: [{ planId: 'self-hosted' }] as Cluster['plans'] });
+ expect(describeUnsendableDomainChange(selfManaged, { add: [], remove: 'dom-a' }, [bound])?.message).toMatch(
+ /self-managed/,
+ );
+ });
+
+ it.each([
+ ['missing from the list', [free], "isn't one of this organization's domains"],
+ ['not verified', [{ ...bound, status: 'PENDING_VALIDATION' }, free], 'is not verified'],
+ ['bound to another cluster', [{ ...bound, clusterId: 'clu-2' }, free], 'is bound to cluster clu-2'],
+ ])('refuses every change while a domain the cluster lists is %s, pointing at support', (_case, domains, problem) => {
+ expect(describeUnsendableDomainChange(held, { add: ['dom-c'], remove: undefined }, domains)).toEqual({
+ message: expect.stringContaining(problem),
+ needsSupport: true,
+ });
+ });
+
+ it('refuses unbinding a listed domain another cluster holds, since central manager would unbind it there', () => {
+ expect(
+ describeUnsendableDomainChange(held, { add: [], remove: 'dom-a' }, [{ ...bound, clusterId: 'clu-2' }]),
+ ).toEqual({ message: expect.stringContaining('is bound to cluster clu-2'), needsSupport: true });
+ });
+
+ it.each([
+ ['missing from the list', [bound], "dom-c isn't one of this organization's domains"],
+ ['not verified', [bound, { ...free, status: 'PENDING_VALIDATION' }], 'c.example.com is not verified'],
+ ['bound to another cluster', [bound, { ...free, clusterId: 'clu-2' }], 'c.example.com is bound to cluster clu-2'],
+ ])('refuses binding a domain that is %s, without pointing at support', (_case, domains, problem) => {
+ expect(describeUnsendableDomainChange(held, { add: ['dom-c'], remove: undefined }, domains)).toEqual({
+ message: expect.stringContaining(problem),
+ needsSupport: false,
+ });
+ });
+
+ it('refuses unbinding a domain the cluster does not list', () => {
+ const unlisted = clusterWith({ domainIds: [] });
+ expect(describeUnsendableDomainChange(unlisted, { add: [], remove: 'dom-a' }, [bound])).toEqual({
+ message: expect.stringContaining('without the cluster listing it'),
+ needsSupport: true,
+ });
+ expect(
+ describeUnsendableDomainChange(unlisted, { add: [], remove: 'dom-c' }, [free]),
+ ).toEqual({ message: expect.stringContaining('is no longer bound to this cluster'), needsSupport: false });
+ });
+});
diff --git a/src/features/clusters/mutations/describeDomainChangeRefusal.ts b/src/features/clusters/mutations/describeDomainChangeRefusal.ts
new file mode 100644
index 000000000..42ded6f18
--- /dev/null
+++ b/src/features/clusters/mutations/describeDomainChangeRefusal.ts
@@ -0,0 +1,123 @@
+import { Cluster, SchemaOrganizationDomain } from '@/integrations/api/api.patch';
+import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged';
+
+export interface DomainChangeRefusal {
+ message: string;
+ /** Nothing on the page clears it: the cluster's own record needs fixing. */
+ needsSupport: boolean;
+}
+
+// Central manager's `assertClusterMutable` refuses every `PUT /Cluster/{id}` while an instance is
+// FAILED or in `TRANSITIONING_INSTANCE_STATES` (src/lib/sharedHelpers.js, src/constants/
+// sharedConstants.js). Hand-kept and never wider than that list: a status missing here still reaches
+// central manager, whose own refusal is then shown. Not `isRunning`, which passes UPDATED.
+const settlingInstanceStatuses = new Set([
+ 'PROVISIONING',
+ 'PENDING_UPGRADE',
+ 'DRAINING',
+ 'UPDATING',
+ 'UPDATED',
+ 'CLONE_PENDING',
+ 'CLONE_READY',
+ 'CLONING',
+ 'UPDATING_HDB_NODES',
+ 'GENERATE_TOKEN',
+ 'TOKEN_GENERATED',
+ 'STOPPING',
+ 'STOPPED',
+ 'STARTING',
+ 'RESTARTING',
+]);
+
+export function describeClusterNotChangeable(cluster: Cluster): DomainChangeRefusal | undefined {
+ if (cluster.status !== 'RUNNING') {
+ return {
+ message: `This cluster is ${cluster.status ?? 'not running'}. Its domains can change once it is running again.`,
+ needsSupport: false,
+ };
+ }
+ for (const instance of cluster.instances ?? []) {
+ const name = instance.instanceFqdn || instance.id;
+ if (instance.status === 'FAILED') {
+ return {
+ message: `Instance ${name} has failed, and this cluster's domains can't change until it is recovered.`,
+ needsSupport: true,
+ };
+ }
+ if (instance.status && settlingInstanceStatuses.has(instance.status)) {
+ return {
+ message:
+ `Instance ${name} is ${instance.status}. This cluster's domains can change once every instance is running.`,
+ needsSupport: false,
+ };
+ }
+ }
+ return undefined;
+}
+
+function domainProblem(
+ clusterId: string,
+ domain: SchemaOrganizationDomain | undefined,
+): string | undefined {
+ if (!domain) {
+ return "isn't one of this organization's domains";
+ }
+ if (domain.status !== 'ACTIVE') {
+ return 'is not verified';
+ }
+ if (domain.clusterId && domain.clusterId !== clusterId) {
+ return `is bound to cluster ${domain.clusterId}`;
+ }
+ return undefined;
+}
+
+/**
+ * Checks every domain a change touches against central manager's per-domain refusals
+ * (updateCluster.js), the ones the cluster already holds included: central manager rejects the
+ * whole request over any one of them, and unbinds whatever the new list leaves out without checking
+ * which cluster that domain is bound to.
+ */
+export function describeUnsendableDomainChange(
+ cluster: Cluster,
+ { add, remove }: { add: string[]; remove: string | undefined },
+ domains: SchemaOrganizationDomain[],
+): DomainChangeRefusal | undefined {
+ if (clusterIsSelfManaged(cluster)) {
+ return { message: "Custom domains can't be bound to a self-managed cluster.", needsSupport: false };
+ }
+ const byId = new Map(domains.map((domain) => [domain.id, domain]));
+ const held = cluster.domainIds ?? [];
+ for (const id of held) {
+ const problem = domainProblem(cluster.id, byId.get(id));
+ if (problem) {
+ return {
+ message: `This cluster still lists ${byId.get(id)?.domain ?? id}, which ${problem}. `
+ + "Its domains can't change until that is cleared.",
+ needsSupport: true,
+ };
+ }
+ }
+ for (const id of add) {
+ const problem = held.includes(id) ? undefined : domainProblem(cluster.id, byId.get(id));
+ if (problem) {
+ return {
+ message: `${byId.get(id)?.domain ?? id} ${problem}, so it can't be bound. The list has been refreshed.`,
+ needsSupport: false,
+ };
+ }
+ }
+ if (remove && !held.includes(remove)) {
+ const domain = byId.get(remove);
+ return domain?.clusterId === cluster.id
+ ? {
+ message:
+ `${domain.domain} is bound to this cluster without the cluster listing it, so it can't be unbound here.`,
+ needsSupport: true,
+ }
+ : {
+ message: `${domain?.domain ?? remove} is no longer bound to this cluster. The list has been refreshed.`,
+ needsSupport: false,
+ };
+ }
+ return undefined;
+}
diff --git a/src/features/clusters/mutations/setDomainIdsOnCluster.ts b/src/features/clusters/mutations/setDomainIdsOnCluster.ts
index 44bd5e754..ef252df5a 100644
--- a/src/features/clusters/mutations/setDomainIdsOnCluster.ts
+++ b/src/features/clusters/mutations/setDomainIdsOnCluster.ts
@@ -1,24 +1,45 @@
import { apiClient } from '@/config/apiClient';
import { watchChallengeCertificates } from '@/features/cluster/domains/queries/getChallengeCertificates';
-import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
+import { getClusterInfo, getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
+import {
+ describeClusterNotChangeable,
+ describeUnsendableDomainChange,
+ DomainChangeRefusal,
+} from '@/features/clusters/mutations/describeDomainChangeRefusal';
+import {
+ getOrganizationDomains,
+ getOrganizationDomainsQueryOptions,
+} from '@/features/organization/queries/getOrganizationDomains';
+import { unique } from '@/lib/arrays/unique';
import { QueryClient, useIsMutating, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
interface Params {
clusterId: string;
- domainIds: string[];
+ organizationId: string;
+ add?: string[];
+ remove?: string;
/** Omit to leave the cluster's cert intent as it is: central manager reads an absent flag as
* unchanged, where `false` revokes it. */
generateDomainCerts?: boolean;
}
-async function setDomainIdsOnCluster({ clusterId, domainIds, generateDomainCerts }: Params) {
+export class DomainChangeRefusedError extends Error {
+ readonly needsSupport: boolean;
+
+ constructor({ message, needsSupport }: DomainChangeRefusal) {
+ super(message);
+ this.name = 'DomainChangeRefusedError';
+ this.needsSupport = needsSupport;
+ }
+}
+
+async function setDomainIdsOnCluster(clusterId: string, domainIds: string[], generateDomainCerts?: boolean) {
// TODO: API does not describe this endpoint permutation
- const { data } = await apiClient.put(`/Cluster/${clusterId}` as '/Cluster/{id}', {
+ await apiClient.put(`/Cluster/${clusterId}` as '/Cluster/{id}', {
domainIds,
generateDomainCerts,
}, { timeout: 0 });
- return data;
}
const setDomainIdsMutationKey = ['setDomainIdsOnCluster'] as const;
@@ -27,19 +48,42 @@ export function useSetDomainIdsOnCluster() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: setDomainIdsMutationKey,
- mutationFn: (params: Params) => setDomainIdsOnCluster(params),
- onSuccess: (_data, { clusterId, domainIds }) => {
- // Every change sends the whole list, built from this cache, so it has to hold the list
- // this one left even if the re-read below fails.
- const { queryKey } = getClusterInfoQueryOptions(clusterId);
- queryClient.setQueryData(queryKey, (cluster) => cluster && { ...cluster, domainIds });
- void queryClient.invalidateQueries({ queryKey, exact: true });
+ mutationFn: async ({ clusterId, organizationId, add = [], remove, generateDomainCerts }: Params) => {
+ // The PUT replaces the whole list and central manager unbinds whatever it leaves out, so the
+ // list is built from a fresh read, not from what this page last saw: another tab or member
+ // may have bound a domain since. Read around the query cache, whose error handler would toast
+ // a failed read this mutation already reports and the page already shows.
+ const [cluster, domains] = await Promise.all([
+ getClusterInfo(clusterId),
+ getOrganizationDomains(organizationId),
+ ]);
+ queryClient.setQueryData(getClusterInfoQueryOptions(clusterId).queryKey, cluster);
+ queryClient.setQueryData(getOrganizationDomainsQueryOptions(organizationId).queryKey, domains);
+ const refusal = describeClusterNotChangeable(cluster)
+ ?? describeUnsendableDomainChange(cluster, { add, remove }, domains);
+ if (refusal) {
+ throw new DomainChangeRefusedError(refusal);
+ }
+ const domainIds = unique((cluster.domainIds ?? []).concat(add)).filter((id) => id !== remove);
+ await setDomainIdsOnCluster(clusterId, domainIds, generateDomainCerts);
+ },
+ // The page shows the failure where the change was made, so the fading toast is off. Reported
+ // here rather than in a caller's `onError`, which React Query skips once the page unmounts. A
+ // refusal is Studio declining to send, not a failure.
+ meta: { skipGlobalErrorToast: true },
+ onError: (error) => {
+ if (!(error instanceof DomainChangeRefusedError)) {
+ console.error(error);
+ }
+ },
+ onSuccess: (_data, { clusterId }) => {
+ void queryClient.invalidateQueries({ queryKey: getClusterInfoQueryOptions(clusterId).queryKey, exact: true });
void watchChallengeCertificates(queryClient, clusterId);
},
});
const { mutate } = mutation;
// `useIsSettingDomainIds` disables the controls only once React re-renders, so a click already
- // queued behind the first change would otherwise build its list from the same stale cache.
+ // queued behind the first change would otherwise start a second one alongside it.
const mutateWhenIdle = useCallback((variables, options) => {
if (!isSettingDomainIds(queryClient)) {
mutate(variables, options);
diff --git a/src/features/clusters/upsert/ClusterBilling.tsx b/src/features/clusters/upsert/ClusterBilling.tsx
index 8080f949d..3ea712057 100644
--- a/src/features/clusters/upsert/ClusterBilling.tsx
+++ b/src/features/clusters/upsert/ClusterBilling.tsx
@@ -1,5 +1,6 @@
import { ContactUs } from '@/components/ContactUs';
import { Button } from '@/components/ui/button';
+import { SubmitErrorMessage } from '@/features/auth/components/SubmitErrorMessage';
import { PaymentMethodsDisplay } from '@/features/organization/billing/paymentMethod/PaymentMethodsDisplay';
import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery';
import { SchemaPlan } from '@/integrations/api/api.gen';
@@ -19,6 +20,7 @@ interface ClusterBillingProps {
readonly onSubmit?: () => void;
readonly organizationId: string;
readonly selectedPlan: SchemaPlan | undefined;
+ readonly submitError?: string;
}
export function ClusterBilling({
@@ -29,6 +31,7 @@ export function ClusterBilling({
onSubmit,
organizationId,
selectedPlan,
+ submitError,
}: ClusterBillingProps) {
const { data: organization } = useQuery(getOrganizationQueryOptions(organizationId));
const billing = organization?.billing;
@@ -42,6 +45,11 @@ export function ClusterBilling({
const footer = (
<>
+ {submitError && (
+