diff --git a/DESIGN.md b/DESIGN.md
index ae41b998a..c59b4dbe4 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -8,7 +8,7 @@ This file collects architecture and design notes for studio that aren't otherwis
- [`src/features/clusters/DESIGN.md`](src/features/clusters/DESIGN.md) — cluster list lifecycle classification, optional metadata and notification scope, and when the cluster form may terminate a cluster.
-- [`src/features/cluster/DESIGN.md`](src/features/cluster/DESIGN.md) — when the Starting-up and Scaling screens may report done, how clone progress is read, and where the cluster card's sync chip gets its data.
+- [`src/features/cluster/DESIGN.md`](src/features/cluster/DESIGN.md) — when the Starting-up and Scaling screens may report done, how clone progress is read, where the cluster card's sync chip gets its data, how custom-domain cert intent, certificate progress and the Bind selection work, and why a domain change is built from a fresh read.
- [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, why a table without a primary key can't be listed, why a filtered list pages without a count, why a table with a computed attribute exports by naming its columns, where columns for undeclared properties come from, and why a filter no index can answer asks first.
diff --git a/src/features/cluster/DESIGN.md b/src/features/cluster/DESIGN.md
index 87dbf1862..f5b018903 100644
--- a/src/features/cluster/DESIGN.md
+++ b/src/features/cluster/DESIGN.md
@@ -58,3 +58,48 @@ page for anything else. Handle it there, not per page: every page under the rout
The cluster card links a `PROVISIONING` cluster to Starting-up and an `UPDATING` one to Scaling for every viewer, so a
non-admin on a new cluster lands on that message instead of a dead card
([HarperFast/studio#1773](https://github.com/HarperFast/studio/issues/1773)).
+
+## Custom domains: cert intent is cluster-wide, and certificate progress is polled
+
+`generateDomainCerts` is one flag for the whole cluster. Central manager reads an absent flag on a `domainIds` edit as
+unchanged, and stamps the result onto every running instance and the Cluster row, which later expansions read
+([HarperFast/central-manager#669](https://github.com/HarperFast/central-manager/pull/669)). So only a bind states it:
+an unbind that sent `false` would revoke it for every domain still bound
+([HarperFast/studio#1626](https://github.com/HarperFast/studio/issues/1626)).
+
+Nothing pushes certificate progress. Central manager answers the domain change before host manager acts on it; host
+manager then upserts a `ChallengeCertificate` row per added domain, and Studio reads progress from that row's
+`issueDate` and `inProgress`. The Domains table polls those rows at 5s while one is generating or for three minutes
+after a domain change (`watchChallengeCertificates`, which the mutation calls), and once a minute otherwise. A cluster
+without the table answers 404, which `getSearchByValue` reads as an empty result, so an idle poll never errors and
+nothing else stops it ([HarperFast/studio#1630](https://github.com/HarperFast/studio/issues/1630)).
+
+The Bind selection is page state that outlives its rows, so Bind counts and sends only the ticked domains the current
+list still offers for binding (`canBindDomain`, the same predicate that renders the checkbox). A removal takes its row
+out of the cached list as soon as it succeeds, rather than when the refetch lands, and Bind waits while one is in flight
+([HarperFast/studio#1800](https://github.com/HarperFast/studio/issues/1800)).
+
+## Custom domains: a domain change is built and checked from a fresh read
+
+`PUT /Cluster/{id}` replaces the cluster's whole `domainIds` list, and central manager unbinds every domain the new
+list leaves out without checking which cluster that domain is bound to. So callers of `useSetDomainIdsOnCluster` pass
+intent (`add`, `remove`), and the mutation builds the list from a fresh read of the cluster and of the organization's
+domains, never from what the page last saw: another tab or member may have bound a domain since.
+
+Before sending, it refuses what central manager would refuse, so the user gets a reason instead of a bare 400
+([HarperFast/studio#1686](https://github.com/HarperFast/studio/issues/1686)), in
+[`describeDomainChangeRefusal.ts`](../clusters/mutations/describeDomainChangeRefusal.ts):
+
+- **The cluster can't change.** Central manager's `assertClusterMutable` refuses every cluster update, edits included,
+ unless the cluster is `RUNNING` and no instance is `FAILED` or in its `TRANSITIONING_INSTANCE_STATES`, which include
+ `STOPPED` and `UPDATED`. Studio keeps a copy of that list. A status missing from the copy still reaches central
+ manager, whose own refusal is then shown, so the copy must never list more than central manager's does.
+- **A domain it touches is refused.** That covers a missing, unverified or foreign-bound domain, and a self-managed
+ cluster. Studio refuses rather than drops. Dropping a domain bound elsewhere would unbind it there. Dropping a deleted
+ id doesn't help either: Harper resolves a missing id in the `domains` relationship to `undefined`, so central
+ manager's own removal loop fails on such a cluster whatever the request carries. A refusal over a domain the cluster
+ already lists points at support, since nothing on the page clears it.
+
+Failures stay on the page beside the control, for both domain changes and cluster edits, and the global toast is off
+for both mutations. Each mutation reports a real request failure to RUM itself, at mutation level, but not a refusal,
+which is Studio declining to send.
diff --git a/src/features/cluster/domains/Management.test.tsx b/src/features/cluster/domains/Management.test.tsx
index 2d6a874f9..b2e99421d 100644
--- a/src/features/cluster/domains/Management.test.tsx
+++ b/src/features/cluster/domains/Management.test.tsx
@@ -1,17 +1,19 @@
/**
* @vitest-environment jsdom
*/
-import { MutationCache, onlineManager, QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { fireEvent, render, waitFor } from '@testing-library/react';
+import { MutationCache, onlineManager, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, fireEvent, render, waitFor, within } from '@testing-library/react';
import { AxiosError } from 'axios';
import { PropsWithChildren, Suspense } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-const { get, post } = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }));
-vi.mock('@/config/apiClient', () => ({ apiClient: { get, post } }));
+const { get, post, put, del } = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() }));
+vi.mock('@/config/apiClient', () => ({ apiClient: { get, post, put, delete: del } }));
+// One object, mutated rather than replaced: the page reads its params into dependency arrays.
+const { routeParams } = vi.hoisted(() => ({ routeParams: { organizationId: 'org-1', clusterId: 'clu-1' } }));
vi.mock('@tanstack/react-router', () => ({
- useParams: () => ({ organizationId: 'org-1', clusterId: 'clu-1' }),
+ useParams: () => routeParams,
Link: ({ children }: PropsWithChildren) => {children},
}));
@@ -28,7 +30,7 @@ vi.mock('@/hooks/usePermissions', () => ({
const { instancePost } = vi.hoisted(() => ({ instancePost: vi.fn() }));
vi.mock('@/config/getInstanceClient', () => ({ getInstanceClient: () => ({ post: instancePost }) }));
-import { mutationErrorHandler } from '@/react-query/queryClient';
+import { mutationErrorHandler, queryErrorHandler } from '@/react-query/queryClient';
import { toast } from 'sonner';
import { DomainsManagement } from './Management';
@@ -46,16 +48,19 @@ function conflict(): AxiosError {
function renderManagement() {
const queryClient = new QueryClient({
+ queryCache: new QueryCache({ onError: queryErrorHandler }),
mutationCache: new MutationCache({ onError: mutationErrorHandler }),
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
- return render(
+ const page = () => (
loading}>
- ,
+
);
+ const rendered = render(page());
+ return { queryClient, ...rendered, rerenderPage: () => rendered.rerender(page()) };
}
function domainListFetches(): number {
@@ -84,7 +89,10 @@ beforeEach(() => {
// `clearAllMocks` keeps implementations, so a `post.mockRejectedValue` would leak into the next
// test; reset so a test that forgets to set `post` fails instead of inheriting one.
-afterEach(() => vi.resetAllMocks());
+afterEach(() => {
+ vi.resetAllMocks();
+ routeParams.clusterId = 'clu-1';
+});
describe('DomainsManagement — adding a domain', () => {
it('states the conflict under the input instead of only in a toast that fades', async () => {
@@ -405,3 +413,711 @@ describe('DomainsManagement — adding a domain', () => {
await waitFor(() => expect(queryByText(/Domain already exists/)).toBeNull());
});
});
+
+describe('DomainsManagement — binding and unbinding', () => {
+ const boundDomains = [
+ { id: 'dom-a', domain: 'a.example.com', status: 'ACTIVE', clusterId: 'clu-1', organizationId: 'org-1' },
+ { id: 'dom-b', domain: 'b.example.com', status: 'ACTIVE', clusterId: 'clu-1', organizationId: 'org-1' },
+ ];
+
+ function serve(domains: object[], clusterDomainIds: () => string[]) {
+ get.mockImplementation((url: string) =>
+ url.startsWith('/Domain/')
+ ? Promise.resolve({ data: domains })
+ : Promise.resolve({
+ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: clusterDomainIds() },
+ })
+ );
+ }
+
+ function sentBody(call = 0): unknown {
+ expect(put.mock.calls[call][0]).toBe('/Cluster/clu-1');
+ return JSON.parse(JSON.stringify(put.mock.calls[call][1]));
+ }
+
+ async function unbind(findByText: (text: string) => Promise, domain: string) {
+ const row = (await findByText(domain)).closest('tr')!;
+ fireEvent.click(within(row).getByRole('button', { name: 'Unbind Domain' }));
+ }
+
+ it('leaves cert intent alone when unbinding one domain, so the domains still bound keep theirs', async () => {
+ put.mockResolvedValue({ data: {} });
+ serve(boundDomains, () => ['dom-a', 'dom-b']);
+ const { findByText } = renderManagement();
+
+ await unbind(findByText, 'a.example.com');
+
+ await waitFor(() => expect(put).toHaveBeenCalledOnce());
+ expect(sentBody()).toEqual({ domainIds: ['dom-b'] });
+ });
+
+ it('unbinds against the cluster as it is now, not as it was when the page mounted', async () => {
+ put.mockResolvedValue({ data: {} });
+ let clusterDomainIds = ['dom-a'];
+ let releaseDomainList!: () => void;
+ const domainListHeld = new Promise((resolve) => {
+ releaseDomainList = resolve;
+ });
+ get.mockImplementation((url: string) =>
+ url.startsWith('/Domain/')
+ ? domainListHeld.then(() => ({ data: boundDomains }))
+ : Promise.resolve({
+ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: clusterDomainIds },
+ })
+ );
+ const { container, findByText, queryClient } = renderManagement();
+ await waitFor(() => expect(container.querySelector('input[name="domain"]')).not.toBeNull());
+
+ clusterDomainIds = ['dom-a', 'dom-b'];
+ await queryClient.refetchQueries({ queryKey: ['clu-1'], exact: true });
+ releaseDomainList();
+
+ await unbind(findByText, 'a.example.com');
+
+ await waitFor(() => expect(put).toHaveBeenCalledOnce());
+ expect(sentBody()).toEqual({ domainIds: ['dom-b'] });
+ });
+
+ it('builds the next change on the list the last one left, without waiting for the cluster poll', async () => {
+ let clusterDomainIds = ['dom-a', 'dom-b'];
+ put.mockImplementation((_url: string, body: { domainIds: string[] }) => {
+ clusterDomainIds = body.domainIds;
+ return Promise.resolve({ data: {} });
+ });
+ serve(boundDomains, () => clusterDomainIds);
+ const { findByText, getByText } = renderManagement();
+
+ await unbind(findByText, 'a.example.com');
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Domain unbound!'));
+ const unbindB = await waitFor(() => {
+ const button = within(getByText('b.example.com').closest('tr')!).getByRole('button', {
+ name: 'Unbind Domain',
+ }) as HTMLButtonElement;
+ expect(button.disabled).toBe(false);
+ return button;
+ });
+ fireEvent.click(unbindB);
+
+ await waitFor(() => expect(put).toHaveBeenCalledTimes(2));
+ expect(sentBody(1)).toEqual({ domainIds: [] });
+ });
+
+ it('sends no change built on a cluster it could not re-read', async () => {
+ let changed = false;
+ put.mockImplementation(() => {
+ changed = true;
+ return Promise.resolve({ data: {} });
+ });
+ get.mockImplementation((url: string) => {
+ if (url.startsWith('/Domain/')) {
+ return Promise.resolve({ data: boundDomains });
+ }
+ return changed
+ ? Promise.reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK))
+ : Promise.resolve({
+ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: ['dom-a', 'dom-b'] },
+ });
+ });
+ const { findByText, getByText } = renderManagement();
+
+ await unbind(findByText, 'a.example.com');
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Domain unbound!'));
+ const unbindB = await waitFor(() => {
+ const button = within(getByText('b.example.com').closest('tr')!).getByRole('button', {
+ name: 'Unbind Domain',
+ }) as HTMLButtonElement;
+ expect(button.disabled).toBe(false);
+ return button;
+ });
+ fireEvent.click(unbindB);
+
+ expect(await findByText("The domain change didn't go through: Network Error")).toBeTruthy();
+ expect(put).toHaveBeenCalledOnce();
+ });
+
+ it('holds every domain action while one is in flight, since each sends the whole list', async () => {
+ let settle!: () => void;
+ put.mockReturnValue(
+ new Promise((resolve) => {
+ settle = () => resolve({ data: {} });
+ }),
+ );
+ const unboundDomain = { id: 'dom-c', domain: 'c.example.com', status: 'ACTIVE', organizationId: 'org-1' };
+ serve([...boundDomains, unboundDomain], () => ['dom-a', 'dom-b']);
+ const { findByText, getByRole } = renderManagement();
+ fireEvent.click(within((await findByText('c.example.com')).closest('tr')!).getByRole('checkbox'));
+ const bind = getByRole('button', { name: /Bind 1 Domain/ }) as HTMLButtonElement;
+
+ await unbind(findByText, 'a.example.com');
+
+ await waitFor(() => expect(bind.disabled).toBe(true));
+ settle();
+ await waitFor(() => expect(bind.disabled).toBe(false));
+ });
+
+ it('refuses a second domain action queued before the first one disabled the controls', async () => {
+ put.mockReturnValue(new Promise(() => {}));
+ const unboundDomain = { id: 'dom-c', domain: 'c.example.com', status: 'ACTIVE', organizationId: 'org-1' };
+ serve([...boundDomains, unboundDomain], () => ['dom-a', 'dom-b']);
+ const { findByText, getByRole } = renderManagement();
+ fireEvent.click(within((await findByText('c.example.com')).closest('tr')!).getByRole('checkbox'));
+ const unbindA = within((await findByText('a.example.com')).closest('tr')!).getByRole('button', {
+ name: 'Unbind Domain',
+ });
+ const bind = getByRole('button', { name: /Bind 1 Domain/ });
+
+ fireEvent.click(unbindA);
+ fireEvent.click(bind);
+
+ await waitFor(() => expect(put).toHaveBeenCalled());
+ expect(put).toHaveBeenCalledOnce();
+ expect(sentBody()).toEqual({ domainIds: ['dom-b'] });
+ });
+
+ it('still asks for certificates when binding', async () => {
+ put.mockResolvedValue({ data: {} });
+ serve([{ id: 'dom-c', domain: 'c.example.com', status: 'ACTIVE', organizationId: 'org-1' }], () => []);
+ const { findByText, getByRole } = renderManagement();
+
+ const row = (await findByText('c.example.com')).closest('tr')!;
+ fireEvent.click(within(row).getByRole('checkbox'));
+ fireEvent.click(getByRole('button', { name: /Bind 1 Domain/ }));
+
+ await waitFor(() => expect(put).toHaveBeenCalledOnce());
+ expect(sentBody()).toEqual({ domainIds: ['dom-c'], generateDomainCerts: true });
+ });
+
+ describe("refetches the domain list through the page's own query client", () => {
+ const unboundC = { id: 'dom-c', domain: 'c.example.com', status: 'ACTIVE', organizationId: 'org-1' };
+
+ // Counted when the request goes out, so a read made before sending it can't pass for the refetch.
+ let fetchesAtSend = 0;
+ function answerCounting(data: unknown) {
+ return () => {
+ fetchesAtSend = domainListFetches();
+ return Promise.resolve({ data });
+ };
+ }
+
+ async function expectListRefetchedAfter(landed: string) {
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith(landed));
+ await waitFor(() => expect(domainListFetches()).toBeGreaterThan(fetchesAtSend));
+ }
+
+ it('once a bind lands', async () => {
+ put.mockImplementation(answerCounting({}));
+ serve([unboundC], () => []);
+ const { findByText, getByRole } = renderManagement();
+ fireEvent.click(within((await findByText('c.example.com')).closest('tr')!).getByRole('checkbox'));
+
+ fireEvent.click(getByRole('button', { name: /Bind 1 Domain/ }));
+
+ await expectListRefetchedAfter('Domain(s) bound! Certificates are being generated in the background now.');
+ });
+
+ it('once an unbind lands', async () => {
+ put.mockImplementation(answerCounting({}));
+ serve(boundDomains, () => ['dom-a', 'dom-b']);
+ const { findByText } = renderManagement();
+
+ await unbind(findByText, 'a.example.com');
+
+ await expectListRefetchedAfter('Domain unbound!');
+ });
+
+ it('once a removal lands', async () => {
+ del.mockImplementation(answerCounting(undefined));
+ serve([unboundC], () => []);
+ const { findByText } = renderManagement();
+ const row = (await findByText('c.example.com')).closest('tr')!;
+
+ fireEvent.click(within(row).getByRole('button', { name: 'Remove Domain' }));
+
+ await expectListRefetchedAfter('Domain removed!');
+ });
+ });
+
+ describe('a ticked domain the list no longer offers', () => {
+ const unboundC = { id: 'dom-c', domain: 'c.example.com', status: 'ACTIVE', organizationId: 'org-1' };
+ const unboundD = { id: 'dom-d', domain: 'd.example.com', status: 'ACTIVE', organizationId: 'org-1' };
+
+ function serveList(list: () => object[]) {
+ get.mockImplementation((url: string) =>
+ url.startsWith('/Domain/')
+ ? Promise.resolve({ data: list() })
+ : Promise.resolve({ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: [] } })
+ );
+ }
+
+ function rowOf(getByText: (text: string) => HTMLElement, domain: string) {
+ return within(getByText(domain).closest('tr')!);
+ }
+
+ async function tickBoth(
+ findByText: (text: string) => Promise,
+ getByText: (text: string) => HTMLElement,
+ ) {
+ await findByText('c.example.com');
+ fireEvent.click(rowOf(getByText, 'c.example.com').getByRole('checkbox'));
+ fireEvent.click(rowOf(getByText, 'd.example.com').getByRole('checkbox'));
+ }
+
+ it('is not offered or sent by Bind once it has been removed, without waiting for the list to refetch', async () => {
+ let removed = false;
+ get.mockImplementation((url: string) => {
+ if (!url.startsWith('/Domain/')) {
+ return Promise.resolve({ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: [] } });
+ }
+ return Promise.resolve({ data: removed ? [unboundD] : [unboundC, unboundD] });
+ });
+ del.mockImplementation(() => {
+ removed = true;
+ return Promise.resolve({ data: undefined });
+ });
+ put.mockResolvedValue({ data: {} });
+ const { findByText, getByText, findByRole } = renderManagement();
+ await tickBoth(findByText, getByText);
+
+ fireEvent.click(rowOf(getByText, 'c.example.com').getByRole('button', { name: 'Remove Domain' }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Domain removed!'));
+ const bind = await findByRole('button', { name: /Bind 1 Domain$/ }) as HTMLButtonElement;
+ await waitFor(() => expect(bind.disabled).toBe(false));
+ fireEvent.click(bind);
+
+ await waitFor(() => expect(put).toHaveBeenCalledOnce());
+ expect(sentBody()).toEqual({ domainIds: ['dom-d'], generateDomainCerts: true });
+ });
+
+ it('is not offered once the list shows it bound to another cluster', async () => {
+ let list: object[] = [unboundC];
+ serveList(() => list);
+ const { findByText, getByText, queryByRole, queryClient } = renderManagement();
+ await findByText('c.example.com');
+ fireEvent.click(rowOf(getByText, 'c.example.com').getByRole('checkbox'));
+ expect(queryByRole('button', { name: /Bind 1 Domain/ })).not.toBeNull();
+
+ list = [{ ...unboundC, clusterId: 'clu-2' }];
+ await queryClient.refetchQueries({ queryKey: ['org-1', 'domains'] });
+
+ await waitFor(() => expect(queryByRole('button', { name: /Bind \d+ Domain/ })).toBeNull());
+ });
+
+ it('holds Bind while a removal is in flight', async () => {
+ let settle!: () => void;
+ serveList(() => [unboundC, unboundD]);
+ del.mockReturnValue(
+ new Promise((resolve) => {
+ settle = () => resolve({ data: undefined });
+ }),
+ );
+ const { findByText, getByText, getByRole } = renderManagement();
+ await tickBoth(findByText, getByText);
+ const bind = getByRole('button', { name: /Bind 2 Domains/ }) as HTMLButtonElement;
+
+ fireEvent.click(rowOf(getByText, 'c.example.com').getByRole('button', { name: 'Remove Domain' }));
+
+ await waitFor(() => expect(bind.disabled).toBe(true));
+ settle();
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Domain removed!'));
+ });
+
+ it('refuses a Bind queued behind a removal before the controls disabled', async () => {
+ serveList(() => [unboundC, unboundD]);
+ del.mockReturnValue(new Promise(() => {}));
+ const { findByText, getByText, getByRole } = renderManagement();
+ await tickBoth(findByText, getByText);
+ const bind = getByRole('button', { name: /Bind 2 Domains/ });
+
+ fireEvent.click(rowOf(getByText, 'c.example.com').getByRole('button', { name: 'Remove Domain' }));
+ fireEvent.click(bind);
+
+ await waitFor(() => expect(del).toHaveBeenCalledOnce());
+ await waitFor(() => expect((bind as HTMLButtonElement).disabled).toBe(true));
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it('is not removed by a click queued behind a Bind that is sending it', async () => {
+ serveList(() => [unboundC, unboundD]);
+ put.mockReturnValue(new Promise(() => {}));
+ const { findByText, getByText, getByRole } = renderManagement();
+ await tickBoth(findByText, getByText);
+ const remove = rowOf(getByText, 'c.example.com').getByRole('button', { name: 'Remove Domain' });
+
+ fireEvent.click(getByRole('button', { name: /Bind 2 Domains/ }));
+ fireEvent.click(remove);
+
+ await waitFor(() => expect(put).toHaveBeenCalledOnce());
+ // Re-queried: the row re-renders as a new element once the controls disable.
+ await waitFor(() =>
+ expect(
+ (rowOf(getByText, 'c.example.com').getByRole('button', { name: 'Remove Domain' }) as HTMLButtonElement)
+ .disabled,
+ ).toBe(true)
+ );
+ expect(del).not.toHaveBeenCalled();
+ });
+
+ it('is not sent by a click that lands after the list changed but before the button re-rendered', async () => {
+ serveList(() => [unboundC, unboundD]);
+ put.mockResolvedValue({ data: {} });
+ const { findByText, getByText, getByRole, queryClient } = renderManagement();
+ await tickBoth(findByText, getByText);
+ const bind = getByRole('button', { name: /Bind 2 Domains/ });
+
+ // React Query notifies observers on a later tick, so the click below still runs the
+ // handler from the render that showed both domains.
+ queryClient.setQueryData(['org-1', 'domains'], [unboundD]);
+ fireEvent.click(bind);
+
+ await waitFor(() => expect(put).toHaveBeenCalledOnce());
+ expect(sentBody()).toEqual({ domainIds: ['dom-d'], generateDomainCerts: true });
+ });
+ });
+
+ it('re-reads certificate progress as soon as a domain change lands, rather than on the idle poll', async () => {
+ put.mockResolvedValue({ data: {} });
+ serve(boundDomains, () => ['dom-a', 'dom-b']);
+ const { findByText } = renderManagement();
+ await findByText('a.example.com');
+ await waitFor(() => expect(instancePost).toHaveBeenCalledOnce());
+
+ await unbind(findByText, 'a.example.com');
+
+ await waitFor(() => expect(instancePost).toHaveBeenCalledTimes(2));
+ expect(instancePost.mock.calls[1][1]).toMatchObject({
+ operation: 'search_by_value',
+ table: 'ChallengeCertificate',
+ });
+ });
+});
+
+describe('DomainsManagement — a domain change central manager would refuse', () => {
+ const domainA = {
+ id: 'dom-a',
+ domain: 'a.example.com',
+ status: 'ACTIVE',
+ clusterId: 'clu-1',
+ organizationId: 'org-1',
+ };
+ const domainC = { id: 'dom-c', domain: 'c.example.com', status: 'ACTIVE', organizationId: 'org-1' };
+ const instanceNotRunning =
+ 'Cluster has an instance that is not RUNNING (ins-1: FAILED). Resolve the instance before the update.';
+ const shownForInstanceNotRunning = `The domain change didn't go through: ${instanceNotRunning}`;
+
+ /** Central manager's `assertClusterMutable` refusal, through Harper's REST problem-details serializer. */
+ function refused(title: string): AxiosError {
+ return {
+ isAxiosError: true,
+ message: 'Request failed with status code 400',
+ response: { status: 400, data: { type: 'error:ClientError', code: 'ClientError', title, status: 400 } },
+ } as AxiosError;
+ }
+
+ function serve(domains: () => object[], cluster: object = {}) {
+ get.mockImplementation((url: string) =>
+ url.startsWith('/Domain/')
+ ? Promise.resolve({ data: domains() })
+ : Promise.resolve({
+ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: ['dom-a'], ...cluster },
+ })
+ );
+ }
+
+ type Rendered = ReturnType;
+
+ async function tickC({ findByText }: Rendered) {
+ fireEvent.click(within((await findByText('c.example.com')).closest('tr')!).getByRole('checkbox'));
+ }
+
+ async function bindC(rendered: Rendered) {
+ await tickC(rendered);
+ fireEvent.click(rendered.getByRole('button', { name: /Bind 1 Domain/ }));
+ }
+
+ async function unbindA({ findByText }: Rendered) {
+ fireEvent.click(
+ within((await findByText('a.example.com')).closest('tr')!).getByRole('button', { name: 'Unbind Domain' }),
+ );
+ }
+
+ let consoleError: ReturnType;
+ beforeEach(() => {
+ consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+ afterEach(() => consoleError.mockRestore());
+
+ it("keeps central manager's reason on the page when it refuses a bind, instead of a toast that fades", async () => {
+ serve(() => [domainA, domainC]);
+ put.mockRejectedValue(refused(instanceNotRunning));
+ const rendered = renderManagement();
+
+ await bindC(rendered);
+
+ expect((await rendered.findByText(shownForInstanceNotRunning)).getAttribute('role')).toBe('alert');
+ expect(toast.error).not.toHaveBeenCalled();
+ expect(consoleError).toHaveBeenCalledWith(expect.objectContaining({ response: expect.anything() }));
+ });
+
+ it('shows a refused unbind the same way', async () => {
+ serve(() => [domainA, domainC]);
+ put.mockRejectedValue(refused(instanceNotRunning));
+ const rendered = renderManagement();
+
+ await unbindA(rendered);
+
+ expect(await rendered.findByText(shownForInstanceNotRunning)).toBeTruthy();
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it('drops the reason once the next change goes through', async () => {
+ serve(() => [domainA, domainC]);
+ put.mockRejectedValueOnce(refused(instanceNotRunning)).mockResolvedValue({ data: {} });
+ const rendered = renderManagement();
+ await bindC(rendered);
+ await rendered.findByText(shownForInstanceNotRunning);
+
+ const bind = rendered.getByRole('button', { name: /Bind 1 Domain/ }) as HTMLButtonElement;
+ await waitFor(() => expect(bind.disabled).toBe(false));
+ fireEvent.click(bind);
+
+ await waitFor(() => expect(put).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(rendered.queryByText(shownForInstanceNotRunning)).toBeNull());
+ });
+
+ // UPDATED is the trap: Studio's `isRunning` counts it as running, and central manager does not.
+ it.each(['FAILED', 'STOPPED', 'UPDATED'])('sends nothing while an instance is %s, and says which', async (status) => {
+ serve(() => [domainA, domainC], {
+ instances: [
+ { id: 'ins-2', instanceFqdn: 'node-2.example.com', status: 'RUNNING' },
+ { id: 'ins-1', instanceFqdn: 'node-1.example.com', status },
+ ],
+ });
+ const rendered = renderManagement();
+
+ await bindC(rendered);
+
+ expect(await rendered.findByText(/Instance node-1\.example\.com/)).toBeTruthy();
+ expect(put).not.toHaveBeenCalled();
+ expect(consoleError).not.toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it('sends no unbind while the cluster is not running', async () => {
+ serve(() => [domainA, domainC], { status: 'UPDATING' });
+ const rendered = renderManagement();
+
+ await unbindA(rendered);
+
+ expect(await rendered.findByText(/This cluster is UPDATING/)).toBeTruthy();
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it('still sends a change while every instance is running or gone', async () => {
+ serve(() => [domainA, domainC], {
+ instances: [
+ { id: 'ins-1', instanceFqdn: 'node-1.example.com', status: 'RUNNING' },
+ { id: 'ins-2', instanceFqdn: 'node-2.example.com', status: 'TERMINATED' },
+ ],
+ });
+ put.mockResolvedValue({ data: {} });
+ const rendered = renderManagement();
+
+ await bindC(rendered);
+
+ await waitFor(() => expect(put).toHaveBeenCalledOnce());
+ });
+
+ it('refuses a ticked domain the fresh list shows bound elsewhere, and stops offering it', async () => {
+ let list: object[] = [domainA, domainC];
+ serve(() => list);
+ const rendered = renderManagement();
+ await tickC(rendered);
+
+ list = [domainA, { ...domainC, clusterId: 'clu-2' }];
+ fireEvent.click(rendered.getByRole('button', { name: /Bind 1 Domain/ }));
+
+ expect(await rendered.findByText(/c\.example\.com is bound to cluster clu-2/)).toBeTruthy();
+ expect(put).not.toHaveBeenCalled();
+ await waitFor(() => expect(rendered.queryByRole('button', { name: /Bind \d+ Domain/ })).toBeNull());
+ });
+
+ it('refuses every change while the cluster still lists a domain that no longer exists, and points at support', async () => {
+ serve(() => [domainA, domainC], { domainIds: ['dom-a', 'dom-gone'] });
+ const rendered = renderManagement();
+
+ await unbindA(rendered);
+
+ expect(await rendered.findByText(/still lists dom-gone, which isn't one of this organization's domains/))
+ .toBeTruthy();
+ expect(rendered.getByRole('link', { name: 'Contact us' })).toBeTruthy();
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it('builds the list from the cluster as central manager has it, keeping a domain bound meanwhile', async () => {
+ let clusterDomainIds = ['dom-a'];
+ let domains: object[] = [domainA, domainC];
+ const domainB = {
+ id: 'dom-b',
+ domain: 'b.example.com',
+ status: 'ACTIVE',
+ clusterId: 'clu-1',
+ organizationId: 'org-1',
+ };
+ get.mockImplementation((url: string) =>
+ url.startsWith('/Domain/')
+ ? Promise.resolve({ data: domains })
+ : Promise.resolve({
+ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: clusterDomainIds },
+ })
+ );
+ put.mockResolvedValue({ data: {} });
+ const rendered = renderManagement();
+ await tickC(rendered);
+
+ // Another member binds dom-b; this page has not re-read the cluster since.
+ clusterDomainIds = ['dom-a', 'dom-b'];
+ domains = [domainA, domainB, domainC];
+ fireEvent.click(rendered.getByRole('button', { name: /Bind 1 Domain/ }));
+
+ await waitFor(() => expect(put).toHaveBeenCalledOnce());
+ expect(JSON.parse(JSON.stringify(put.mock.calls[0][1]))).toEqual({
+ domainIds: ['dom-a', 'dom-b', 'dom-c'],
+ generateDomainCerts: true,
+ });
+ });
+
+ it("does not show one cluster's failed change on another cluster's page", async () => {
+ get.mockImplementation((url: string) =>
+ url.startsWith('/Domain/')
+ ? Promise.resolve({ data: [domainA, domainC] })
+ : Promise.resolve({
+ data: {
+ id: url.endsWith('clu-2') ? 'clu-2' : 'clu-1',
+ organizationId: 'org-1',
+ status: 'RUNNING',
+ domainIds: url.endsWith('clu-2') ? [] : ['dom-a'],
+ },
+ })
+ );
+ put.mockRejectedValue(refused(instanceNotRunning));
+ const rendered = renderManagement();
+ await bindC(rendered);
+ await rendered.findByText(shownForInstanceNotRunning);
+
+ routeParams.clusterId = 'clu-2';
+ rendered.rerenderPage();
+
+ await waitFor(() => expect(get).toHaveBeenCalledWith('/Cluster/clu-2'));
+ // The page suspends on the new cluster, and hides rather than unmounts what it showed.
+ await waitFor(() => expect(rendered.queryByText('loading')).toBeNull());
+ expect(rendered.queryByText(shownForInstanceNotRunning)).toBeNull();
+ });
+
+ it('sends nothing when the fresh domain list cannot be read, and says so', async () => {
+ let listReads = 0;
+ get.mockImplementation((url: string) => {
+ if (!url.startsWith('/Domain/')) {
+ return Promise.resolve({
+ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: ['dom-a'] },
+ });
+ }
+ listReads += 1;
+ return listReads === 1
+ ? Promise.resolve({ data: [domainA, domainC] })
+ : Promise.reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK));
+ });
+ const rendered = renderManagement();
+
+ await bindC(rendered);
+
+ expect(await rendered.findByText("The domain change didn't go through: Network Error")).toBeTruthy();
+ expect(put).not.toHaveBeenCalled();
+ expect(toast.error).not.toHaveBeenCalled();
+ expect(consoleError.mock.calls.filter(([logged]: unknown[]) => logged instanceof AxiosError)).toHaveLength(1);
+ });
+});
+
+describe('DomainsManagement — the Remove Domain tooltip', () => {
+ const boundTip = 'To delete this domain, please unbind it from all clusters first.';
+ const unboundTip = 'Remember to clean up your DNS records!';
+
+ async function renderRemove(clusterId: string | undefined) {
+ const domain = { id: 'dom-a', domain: 'a.example.com', status: 'ACTIVE', organizationId: 'org-1', clusterId };
+ get.mockImplementation((url: string) =>
+ url.startsWith('/Domain/')
+ ? Promise.resolve({ data: [domain] })
+ : Promise.resolve({
+ data: { id: 'clu-1', organizationId: 'org-1', status: 'RUNNING', domainIds: clusterId ? ['dom-a'] : [] },
+ })
+ );
+ const rendered = renderManagement();
+ const name = await rendered.findByText('a.example.com');
+ const cell = name.parentElement!;
+ const remove = within(cell).getByRole('button', { name: 'Remove Domain' }) as HTMLButtonElement;
+ // Radix marks its trigger with `data-state`, whichever element that turns out to be.
+ const trigger = cell.querySelector('[data-state]')!;
+ return { ...rendered, name, cell, remove, trigger };
+ }
+
+ async function expectDescribes(
+ element: HTMLElement,
+ tip: string,
+ findByRole: (role: 'tooltip') => Promise,
+ ) {
+ const tooltip = await findByRole('tooltip');
+ expect(tooltip.textContent).toBe(tip);
+ expect(element.getAttribute('aria-describedby')).toBe(tooltip.id);
+ }
+
+ it.each([
+ { state: 'bound', clusterId: 'clu-1', tip: boundTip },
+ { state: 'unbound', clusterId: undefined, tip: unboundTip },
+ ])('opens over a $state domain', async ({ clusterId, tip }) => {
+ const { name, remove, trigger, findByRole, queryByRole } = await renderRemove(clusterId);
+ expect(remove.disabled).toBe(!!clusterId);
+
+ fireEvent.pointerMove(name);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(queryByRole('tooltip')).toBeNull();
+
+ fireEvent.pointerMove(trigger);
+ expect((await findByRole('tooltip')).textContent).toBe(tip);
+ });
+
+ it('describes a disabled Remove from a wrapper that keyboard focus can reach', async () => {
+ const { remove, trigger, findByRole } = await renderRemove('clu-1');
+ expect(trigger).not.toBe(remove);
+
+ act(() => trigger.focus());
+
+ expect(document.activeElement).toBe(trigger);
+ await expectDescribes(trigger, boundTip, findByRole);
+ });
+
+ it('describes an enabled Remove on the button itself, with no extra tab stop', async () => {
+ const { cell, remove, findByRole } = await renderRemove(undefined);
+ expect(cell.querySelectorAll('[tabindex]')).toHaveLength(0);
+
+ act(() => remove.focus());
+
+ expect(document.activeElement).toBe(remove);
+ await expectDescribes(remove, unboundTip, findByRole);
+ });
+
+ it('adds no tab stop to an unbound domain while another domain change is in flight', async () => {
+ put.mockReturnValue(new Promise(() => {}));
+ const { cell, getByRole, getByText } = await renderRemove(undefined);
+ fireEvent.click(within(cell.closest('tr')!).getByRole('checkbox'));
+
+ fireEvent.click(getByRole('button', { name: /Bind 1 Domain/ }));
+
+ // Re-queried: the row's cells re-render once the change is pending.
+ const pendingCell = await waitFor(() => {
+ const current = getByText('a.example.com').parentElement!;
+ expect((within(current).getByRole('button', { name: 'Remove Domain' }) as HTMLButtonElement).disabled).toBe(true);
+ return current;
+ });
+ expect(pendingCell.querySelectorAll('[tabindex]')).toHaveLength(0);
+ });
+});
diff --git a/src/features/cluster/domains/Management.tsx b/src/features/cluster/domains/Management.tsx
index 84c720c4b..0fc8204d6 100644
--- a/src/features/cluster/domains/Management.tsx
+++ b/src/features/cluster/domains/Management.tsx
@@ -7,7 +7,7 @@ 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 { isRunning } from '@/components/ui/utils/badgeStatus';
+import { SubmitErrorMessage } from '@/features/auth/components/SubmitErrorMessage';
import {
addDomainsSequentially,
describeDomainFailures,
@@ -20,24 +20,30 @@ import {
unresolvedFailures,
withinReconcileWindow,
} from '@/features/cluster/domains/addDomainsSequentially';
+import { bindableDomainIds } from '@/features/cluster/domains/canBindDomain';
import { useDataTableColumns } from '@/features/cluster/domains/constants/tableDefinition';
import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery';
-import { useSetDomainIdsOnCluster } from '@/features/clusters/mutations/setDomainIdsOnCluster';
+import { DomainChangeRefusal } from '@/features/clusters/mutations/describeDomainChangeRefusal';
+import {
+ DomainChangeRefusedError,
+ useIsSettingDomainIds,
+ useSetDomainIdsOnCluster,
+} from '@/features/clusters/mutations/setDomainIdsOnCluster';
import {
AddOrganizationDomainSchema,
DOMAIN_REQUIRED_MESSAGE,
useAddDomainToOrganization,
} from '@/features/organization/mutations/addDomainToOrganization';
+import { isDeletingDomain, useIsDeletingDomain } from '@/features/organization/mutations/deleteDomain';
import { validateDomainInOrganization } from '@/features/organization/mutations/validateDomainInOrganization';
import { getOrganizationDomainsQueryOptions } from '@/features/organization/queries/getOrganizationDomains';
import { useOrganizationRolePermissions } from '@/hooks/usePermissions';
import { useRefreshClick } from '@/hooks/useRefreshClick';
import { SchemaOrganizationDomain } from '@/integrations/api/api.patch';
-import { unique } from '@/lib/arrays/unique';
import { pluralize } from '@/lib/pluralize';
-import { queryClient } from '@/react-query/queryClient';
+import { describeError } from '@/react-query/queryClient';
import { zodResolver } from '@hookform/resolvers/zod';
-import { onlineManager, useQuery, useSuspenseQuery } from '@tanstack/react-query';
+import { onlineManager, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { useParams } from '@tanstack/react-router';
import { ListTodoIcon, PlusIcon, RefreshCwIcon, Save } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
@@ -45,6 +51,12 @@ import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import z from 'zod';
+function describeDomainChangeFailure(error: Error): DomainChangeRefusal {
+ return error instanceof DomainChangeRefusedError
+ ? { message: error.message, needsSupport: error.needsSupport }
+ : { message: `The domain change didn't go through: ${describeError(error).message}`, needsSupport: false };
+}
+
export function DomainsManagement() {
const { organizationId, clusterId }: { organizationId: string; clusterId: string } = useParams({ strict: false });
const { data: cluster } = useSuspenseQuery(
@@ -88,26 +100,60 @@ export function DomainsManagement() {
);
}, []);
- const { mutate: setDomainIds, isPending: isBindPending } = useSetDomainIdsOnCluster();
+ const domainIdsToBind = useMemo(
+ () => bindableDomainIds(selectedDomainIds, organizationDomains, cluster),
+ [cluster, organizationDomains, selectedDomainIds],
+ );
+
+ const domainChange = useSetDomainIdsOnCluster();
+ const { mutate: setDomainIds } = domainChange;
+ const settingDomainIds = useIsSettingDomainIds();
+ const deletingDomain = useIsDeletingDomain();
+ const activeQueryClient = useQueryClient();
const onBindClick = useCallback(() => {
- if (!isRunning(cluster.status)) {
- toast.error('Cluster is currently ' + cluster.status, { description: 'To bind a domain, it must be running.' });
+ // From the query client, not this render: a removal reaches the button a render after it
+ // starts or lands, and a click in between would still send the domain being removed.
+ if (isDeletingDomain(activeQueryClient)) {
+ return;
+ }
+ const toBind = bindableDomainIds(
+ selectedDomainIds,
+ activeQueryClient.getQueryData(getOrganizationDomainsQueryOptions(organizationId).queryKey),
+ cluster,
+ );
+ if (toBind.length === 0) {
return;
}
setDomainIds({
clusterId: cluster.id,
- domainIds: unique((cluster.domainIds?.slice() || []).concat(selectedDomainIds)),
+ organizationId,
+ add: toBind,
generateDomainCerts: true,
}, {
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' });
+ void activeQueryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' });
toast.success('Domain(s) bound! Certificates are being generated in the background now.');
setSelectedDomainIds([]);
},
});
- }, [cluster, selectedDomainIds, setDomainIds]);
+ }, [activeQueryClient, cluster, organizationId, selectedDomainIds, setDomainIds]);
+
+ const onUnbindClick = useCallback((domainId: string) => {
+ // No `generateDomainCerts`: the flag is cluster-wide, so restating it here would decide cert
+ // intent for every domain that stays bound.
+ setDomainIds({ clusterId: cluster.id, organizationId, remove: domainId }, {
+ onSuccess: () => {
+ void activeQueryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' });
+ toast.success('Domain unbound!');
+ },
+ });
+ }, [activeQueryClient, cluster.id, cluster.organizationId, organizationId, setDomainIds]);
+
+ const domainChangeFailure = domainChange.error && domainChange.variables?.clusterId === cluster.id
+ ? describeDomainChangeFailure(domainChange.error)
+ : undefined;
const { mutateAsync: addDomain } = useAddDomainToOrganization();
const form = useForm({
@@ -215,7 +261,7 @@ export function DomainsManagement() {
}
}, [pendingDomains]);
- const dataTableColumns = useDataTableColumns(cluster, selectedDomainIds, onToggleDomainSelection);
+ const dataTableColumns = useDataTableColumns(cluster, selectedDomainIds, onToggleDomainSelection, onUnbindClick);
const domainValue = form.watch('domain');
const isApex = useMemo(() => {
@@ -237,103 +283,106 @@ export function DomainsManagement() {
columns={dataTableColumns}
sortingState={sortingState}
>
-
- {update && (
-
);
diff --git a/src/features/cluster/domains/canBindDomain.test.ts b/src/features/cluster/domains/canBindDomain.test.ts
new file mode 100644
index 000000000..da7014b1e
--- /dev/null
+++ b/src/features/cluster/domains/canBindDomain.test.ts
@@ -0,0 +1,41 @@
+import { Cluster, SchemaOrganizationDomain } from '@/integrations/api/api.patch';
+import { describe, expect, it } from 'vitest';
+import { bindableDomainIds, canBindDomain } from './canBindDomain';
+
+const cluster = { id: 'clu-1', plans: [{ planId: 'standard' }] } as Cluster;
+const domain = { id: 'dom-1', domain: 'a.example.com', status: 'ACTIVE' } as SchemaOrganizationDomain;
+
+describe('canBindDomain', () => {
+ it('offers a verified domain that no cluster holds', () => {
+ expect(canBindDomain(domain, cluster)).toBe(true);
+ });
+
+ it('does not offer a domain still awaiting verification', () => {
+ expect(canBindDomain({ ...domain, status: 'PENDING_VALIDATION' }, cluster)).toBe(false);
+ });
+
+ it('does not offer a domain bound to any cluster, this one included', () => {
+ expect(canBindDomain({ ...domain, clusterId: 'clu-2' }, cluster)).toBe(false);
+ expect(canBindDomain({ ...domain, clusterId: 'clu-1' }, cluster)).toBe(false);
+ });
+
+ it('offers nothing to a self-managed cluster', () => {
+ expect(canBindDomain(domain, { ...cluster, plans: [{ planId: 'self-hosted' }] } as Cluster)).toBe(false);
+ });
+});
+
+describe('bindableDomainIds', () => {
+ const other = { ...domain, id: 'dom-2', domain: 'b.example.com' };
+
+ it('keeps the ticked ids the list still offers, in the order they were ticked', () => {
+ expect(bindableDomainIds(['dom-2', 'dom-1'], [domain, other], cluster)).toEqual(['dom-2', 'dom-1']);
+ });
+
+ it('drops a ticked id the list no longer has, or no longer offers', () => {
+ expect(bindableDomainIds(['dom-1', 'dom-2'], [{ ...domain, clusterId: 'clu-2' }], cluster)).toEqual([]);
+ });
+
+ it('offers nothing before the list has loaded', () => {
+ expect(bindableDomainIds(['dom-1'], undefined, cluster)).toEqual([]);
+ });
+});
diff --git a/src/features/cluster/domains/canBindDomain.ts b/src/features/cluster/domains/canBindDomain.ts
new file mode 100644
index 000000000..cb9ed7c8e
--- /dev/null
+++ b/src/features/cluster/domains/canBindDomain.ts
@@ -0,0 +1,17 @@
+import { Cluster, SchemaOrganizationDomain } from '@/integrations/api/api.patch';
+import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged';
+
+export function canBindDomain(domain: SchemaOrganizationDomain, cluster: Cluster): boolean {
+ return domain.status === 'ACTIVE' && !domain.clusterId && !clusterIsSelfManaged(cluster);
+}
+
+/** The ticked ids the list still offers for binding, in the order they were ticked. */
+export function bindableDomainIds(
+ selectedDomainIds: string[],
+ domains: SchemaOrganizationDomain[] | undefined,
+ cluster: Cluster,
+): string[] {
+ return selectedDomainIds.filter((id) =>
+ domains?.some((domain) => domain.id === id && canBindDomain(domain, cluster))
+ );
+}
diff --git a/src/features/cluster/domains/constants/tableDefinition.tsx b/src/features/cluster/domains/constants/tableDefinition.tsx
index 959637238..9c02801d7 100644
--- a/src/features/cluster/domains/constants/tableDefinition.tsx
+++ b/src/features/cluster/domains/constants/tableDefinition.tsx
@@ -1,17 +1,19 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import { isRunning } from '@/components/ui/utils/badgeStatus';
import { AssociateDomainWithCluster } from '@/features/cluster/domains/AssociateDomainWithCluster';
+import { canBindDomain } from '@/features/cluster/domains/canBindDomain';
import { CertificateProgress } from '@/features/cluster/domains/components/CertificateProgress';
-import { useChallengeCertificates } from '@/features/cluster/domains/queries/getChallengeCertificates';
+import {
+ isGeneratingCertificate,
+ useChallengeCertificates,
+} from '@/features/cluster/domains/queries/getChallengeCertificates';
import { VerifyDomainOwnership } from '@/features/cluster/domains/VerifyDomainOwnership';
-import { useSetDomainIdsOnCluster } from '@/features/clusters/mutations/setDomainIdsOnCluster';
+import { isSettingDomainIds, useIsSettingDomainIds } from '@/features/clusters/mutations/setDomainIdsOnCluster';
import { useDeleteDomain } from '@/features/organization/mutations/deleteDomain';
import { Cluster, SchemaOrganizationDomain } from '@/integrations/api/api.patch';
import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged';
-import { unique } from '@/lib/arrays/unique';
import { ColumnDef, createColumnHelper } from '@/lib/table';
-import { queryClient } from '@/react-query/queryClient';
+import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';
@@ -21,61 +23,37 @@ export function useDataTableColumns(
cluster: Cluster,
selectedDomainIds: string[],
onToggleDomainSelection: (domainId: string) => void,
+ onUnbindClick: (domainId: string) => void,
): Array> {
const { data: challengeCertificates } = useChallengeCertificates(cluster.id);
const isGeneratingCert = useMemo(
- () => !!challengeCertificates?.some((cert) => !cert.issueDate || cert.inProgress),
+ () => !!challengeCertificates?.some(isGeneratingCertificate),
[challengeCertificates],
);
- const { mutate: setDomainIds, isPending: addPending } = useSetDomainIdsOnCluster();
+ const settingDomainIds = useIsSettingDomainIds();
const { mutate: deleteDomain, isPending: deletePending } = useDeleteDomain();
- const isPending = addPending || deletePending;
+ const isPending = settingDomainIds || deletePending;
+ const activeQueryClient = useQueryClient();
- const bindDomainToThisCluster = useCallback((domainId: string, generateDomainCerts: boolean) => {
- if (!isRunning(cluster.status)) {
- toast.error('Cluster is currently ' + cluster.status, { description: 'To bind a domain, it must be running.' });
+ const deleteDomainById = useCallback((domainId: string) => {
+ // A Bind sending this domain disables Remove only once React re-renders.
+ if (isSettingDomainIds(activeQueryClient)) {
return;
}
- setDomainIds({
- clusterId: cluster.id,
- domainIds: unique((cluster.domainIds?.slice() || []).concat([domainId])),
- generateDomainCerts,
- }, {
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' });
- toast.success(
- 'Domain bound!' + (generateDomainCerts
- ? ' Certificates are being generated in the background now.'
- : ''),
- );
- },
- });
- }, [setDomainIds, cluster]);
-
- const deleteDomainById = useCallback((domainId: string) => {
deleteDomain({
domainId: domainId,
+ organizationId: cluster.organizationId,
}, {
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: [cluster.organizationId, 'domains'], refetchType: 'active' });
+ void activeQueryClient.invalidateQueries({
+ queryKey: [cluster.organizationId, 'domains'],
+ refetchType: 'active',
+ });
toast.success('Domain removed!');
},
});
- }, [deleteDomain]);
-
- const unbindDomainById = useCallback((domainId: string) => {
- setDomainIds({
- clusterId: cluster.id,
- domainIds: (cluster.domainIds?.slice() || []).filter((id) => id !== domainId),
- generateDomainCerts: false,
- }, {
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' });
- toast.success('Domain unbound!');
- },
- });
- }, [deleteDomain]);
+ }, [activeQueryClient, deleteDomain, cluster.organizationId]);
return useMemo(() => [
columnHelper.display({
@@ -84,8 +62,7 @@ export function useDataTableColumns(
size: 50,
cell: (props) => {
const domain = props.row.original;
- const canBind = domain.status === 'ACTIVE' && !domain.clusterId && !clusterIsSelfManaged(cluster);
- if (!canBind) {
+ if (!canBindDomain(domain, cluster)) {
return null;
}
return (
@@ -107,31 +84,35 @@ export function useDataTableColumns(
size: 150,
cell: (props) => {
const domain = props.row.original;
+ const removeButton = (
+
+ );
return (
-
-
- <>
-
{domain.domain}
-
- >
-
-
- {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!'}
+
+
+ >
);
},
}),
@@ -150,7 +131,7 @@ export function useDataTableColumns(
return (
<>