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 && ( -
- - ( - - New Domain Name - - - - {isApex && ( -
- Adding an apex domain? - -
- )} - - - Type in a domain like example.com or your.example.com, and you'll be guided through validating - and binding your cluster to it. - - -
- )} - /> -
- + {...field} + /> + + {isApex && ( +
+ Adding an apex domain? + +
+ )} + + + Type in a domain like example.com or your.example.com, and you'll be guided through validating + and binding your cluster to it. + + + + )} + /> +
+ - {pendingDomains.length > 0 && ( + {pendingDomains.length > 0 && ( + + )} - )} - -
- - - )} +
+ + + )} - {selectedDomainIds.length > 0 && ( -
- -
- )} + {domainIdsToBind.length > 0 && ( +
+ +
+ )} +
+ ); 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 ( <>
Bound to this cluster
- {certForDomain && (!certForDomain.issueDate || certForDomain.inProgress) && ( + {certForDomain && isGeneratingCertificate(certForDomain) && (
@@ -160,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 @@ -194,7 +175,8 @@ export function useDataTableColumns( }), ], [ cluster, - bindDomainToThisCluster, + deleteDomainById, + onUnbindClick, isPending, challengeCertificates, isGeneratingCert, diff --git a/src/features/cluster/domains/queries/getChallengeCertificates.test.ts b/src/features/cluster/domains/queries/getChallengeCertificates.test.ts new file mode 100644 index 000000000..837bb001f --- /dev/null +++ b/src/features/cluster/domains/queries/getChallengeCertificates.test.ts @@ -0,0 +1,72 @@ +import { QueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + CERTIFICATE_IDLE_POLL_MS, + CERTIFICATE_PROGRESS_POLL_MS, + CERTIFICATE_WATCH_AFTER_DOMAIN_CHANGE_MS, + ChallengeCertificate, + challengeCertificatesQueryKey, + getChallengeCertificatesQueryOptions, + watchChallengeCertificates, +} from './getChallengeCertificates'; + +vi.mock('@/config/getInstanceClient', () => ({ getInstanceClient: vi.fn() })); + +function certificate(overrides: Partial): ChallengeCertificate { + return { + domain: 'example.com', + issueDate: '2026-09-01T00:00:00Z', + renewalDate: '2026-11-01T00:00:00Z', + challengeToken: 'token', + challengeContent: 'content', + inProgress: false, + ...overrides, + }; +} + +function nextPoll(clusterId: string, data: ChallengeCertificate[] | undefined, error: unknown = null) { + const { refetchInterval } = getChallengeCertificatesQueryOptions(clusterId); + return (refetchInterval as (query: unknown) => number | false | undefined)({ state: { data, error } }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('ChallengeCertificate poll', () => { + it('idles when there is nothing to watch, including the empty result a missing table reads as', () => { + expect(nextPoll('clu-idle', [])).toBe(CERTIFICATE_IDLE_POLL_MS); + expect(nextPoll('clu-idle', undefined)).toBe(CERTIFICATE_IDLE_POLL_MS); + expect(nextPoll('clu-idle', [certificate({})])).toBe(CERTIFICATE_IDLE_POLL_MS); + }); + + it('polls at the progress rate while a certificate is pending or being renewed', () => { + expect(nextPoll('clu-pending', [certificate({}), certificate({ issueDate: null })])).toBe( + CERTIFICATE_PROGRESS_POLL_MS, + ); + expect(nextPoll('clu-pending', [certificate({ inProgress: true })])).toBe(CERTIFICATE_PROGRESS_POLL_MS); + }); + + it('polls at the progress rate for a while after a domain change, then idles again', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-01T00:00:00Z')); + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + + await watchChallengeCertificates(queryClient, 'clu-watched'); + + expect(invalidate).toHaveBeenCalledWith({ queryKey: challengeCertificatesQueryKey('clu-watched') }); + expect(nextPoll('clu-watched', [])).toBe(CERTIFICATE_PROGRESS_POLL_MS); + expect(nextPoll('clu-unrelated', [])).toBe(CERTIFICATE_IDLE_POLL_MS); + + vi.setSystemTime(Date.now() + CERTIFICATE_WATCH_AFTER_DOMAIN_CHANGE_MS + 1); + expect(nextPoll('clu-watched', [])).toBe(CERTIFICATE_IDLE_POLL_MS); + }); + + it('still stops on a 403, whatever the cadence would have been', () => { + const forbidden = new AxiosError('Request failed with status code 403'); + forbidden.response = { status: 403 } as AxiosError['response']; + expect(nextPoll('clu-forbidden', [certificate({ issueDate: null })], forbidden)).toBe(false); + }); +}); diff --git a/src/features/cluster/domains/queries/getChallengeCertificates.ts b/src/features/cluster/domains/queries/getChallengeCertificates.ts index b3e9d74c8..42d0d14e6 100644 --- a/src/features/cluster/domains/queries/getChallengeCertificates.ts +++ b/src/features/cluster/domains/queries/getChallengeCertificates.ts @@ -1,7 +1,7 @@ import { getInstanceClient } from '@/config/getInstanceClient'; import { getSearchByValue } from '@/integrations/api/instance/database/getSearchByValue'; import { pollUnlessForbidden, retryUnlessRejected } from '@/react-query/pollUnlessForbidden'; -import { queryOptions, useQuery } from '@tanstack/react-query'; +import { QueryClient, queryOptions, useQuery } from '@tanstack/react-query'; export interface ChallengeCertificate { domain: string; @@ -12,9 +12,41 @@ export interface ChallengeCertificate { inProgress: boolean; } +export const CERTIFICATE_PROGRESS_POLL_MS = 5_000; +export const CERTIFICATE_IDLE_POLL_MS = 60_000; +/** Central manager answers a domain change before host manager acts on it, and host manager only + * writes a cluster's rows once it reconciles the change — after deploying the cert component and + * restarting, the first time. So the rows a change produces arrive some time after it succeeds. */ +export const CERTIFICATE_WATCH_AFTER_DOMAIN_CHANGE_MS = 3 * 60_000; + +const watchingUntil = new Map(); + +export function isGeneratingCertificate(certificate: ChallengeCertificate): boolean { + return !certificate.issueDate || certificate.inProgress; +} + +export function challengeCertificatesQueryKey(clusterId?: string) { + return [clusterId, 'ChallengeCertificate'] as const; +} + +export function watchChallengeCertificates(queryClient: QueryClient, clusterId: string) { + watchingUntil.set(clusterId, Date.now() + CERTIFICATE_WATCH_AFTER_DOMAIN_CHANGE_MS); + return queryClient.invalidateQueries({ queryKey: challengeCertificatesQueryKey(clusterId) }); +} + +function challengeCertificatePollInterval( + clusterId: string | undefined, + certificates: ChallengeCertificate[] | undefined, +): number { + const watching = !!clusterId && (watchingUntil.get(clusterId) ?? 0) > Date.now(); + return watching || certificates?.some(isGeneratingCertificate) + ? CERTIFICATE_PROGRESS_POLL_MS + : CERTIFICATE_IDLE_POLL_MS; +} + export function getChallengeCertificatesQueryOptions(clusterId?: string) { return queryOptions({ - queryKey: [clusterId, 'ChallengeCertificate'], + queryKey: challengeCertificatesQueryKey(clusterId), queryFn: async () => { if (!clusterId) { return []; } const instanceClient = getInstanceClient({ id: clusterId, forceFabricConnect: true }); @@ -32,9 +64,8 @@ export function getChallengeCertificatesQueryOptions(clusterId?: string) { return data; }, enabled: !!clusterId, - // Same always-on shape as the 10s pollers: gated only on `clusterId`, so a - // user without access to the cluster's data 403s every 5s until they navigate. - refetchInterval: pollUnlessForbidden(5000), + refetchInterval: (query) => + pollUnlessForbidden(challengeCertificatePollInterval(clusterId, query.state.data))(query), retry: retryUnlessRejected(), }); } 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 91c182e97..ef252df5a 100644 --- a/src/features/clusters/mutations/setDomainIdsOnCluster.ts +++ b/src/features/clusters/mutations/setDomainIdsOnCluster.ts @@ -1,23 +1,101 @@ import { apiClient } from '@/config/apiClient'; -import { useMutation } from '@tanstack/react-query'; +import { watchChallengeCertificates } from '@/features/cluster/domains/queries/getChallengeCertificates'; +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[]; - generateDomainCerts: boolean; + 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; + export function useSetDomainIdsOnCluster() { - return useMutation({ - mutationFn: (params: Params) => setDomainIdsOnCluster(params), + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationKey: setDomainIdsMutationKey, + 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 start a second one alongside it. + const mutateWhenIdle = useCallback((variables, options) => { + if (!isSettingDomainIds(queryClient)) { + mutate(variables, options); + } + }, [mutate, queryClient]); + return { ...mutation, mutate: mutateWhenIdle }; +} + +export function isSettingDomainIds(queryClient: QueryClient): boolean { + return queryClient.isMutating({ mutationKey: setDomainIdsMutationKey }) > 0; +} + +export function useIsSettingDomainIds(): boolean { + return useIsMutating({ mutationKey: setDomainIdsMutationKey }) > 0; } 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 && ( +
+ +
+ )}