Repository navigation
A refused domain change or cluster edit explains why on the page, and Studio checks a domain change before sending it - #1846
Conversation
…n the page, and pre-check it
A refused PUT /Cluster/{id} (bind, unbind, or a cluster edit) produced only a toast that faded,
and the domain changes sent whole lists Studio had not checked. RUM showed 39 of 40 such PUTs
refused in a day on one cluster, with users retrying across three pages (#1686).
Domain changes now pass intent (add, remove). The mutation builds the list from a fresh read of
the cluster and the organization's domains rather than from what the page last saw, so a domain
another member bound meanwhile is no longer silently unbound. Before sending, it refuses what
central manager would refuse, mirroring its source:
- the cluster is not RUNNING, or an instance is FAILED or in TRANSITIONING_INSTANCE_STATES
(assertClusterMutable, sharedHelpers.js:587-606; STOPPED and UPDATED included);
- the cluster is self-managed (updateCluster.js:166);
- a domain the change touches is missing, unverified or bound to another cluster
(updateCluster.js:173-191), the cluster's existing ones included; those point at support.
It refuses rather than drops: central manager unbinds whatever the new list leaves out without
checking which cluster holds it (updateCluster.js:199-205).
Bind and unbind failures, refusals and central manager's own reasons alike, now stay on the
Domains page; a cluster edit's failure stays beside its submit button. Both mutations skip the
global toast and report real request failures to RUM at mutation level. #1801's cache write of
the accepted list is gone, since every change now re-reads the cluster.
Refs #1686
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request refactors the custom domain management flow to build and validate domain changes from a fresh read of the cluster and organization domains rather than relying on stale page state. It introduces client-side pre-validation (describeDomainChangeRefusal) to replicate central manager's mutability and domain checks, allowing Studio to present clear, actionable inline error messages (via SubmitErrorMessage) instead of generic 400 errors or fading toasts. Additionally, it updates the edit cluster forms and billing screens to display inline submission errors and adds comprehensive unit tests covering these refusal scenarios. I have no feedback to provide as there are no review comments to evaluate.
Refs #1686
⊙ Problem
PUT /Cluster/{id}is the endpoint behind Bind, Unbind and the cluster edit page. In RUM it was refused 39 times out of 40 in one day, on one cluster, while three sessions retried across three pages (#1686). Studio gave them nothing to go on: each refusal was a toast that faded, and the domain changes sent a wholedomainIdslist built from whatever the page last cached, unchecked. That list is a replacement, and central manager unbinds every domain it leaves out, so a stale list also silently unbinds a domain another member bound meanwhile.RUM has no response bodies, so which refusal #1686 hit isn't provable. The central-manager source narrows it. The only 400 that can block both a domain change and an edit is
assertClusterMutable(updateCluster.js:69→sharedHelpers.js:587-606): it refuses every update unless the cluster isRUNNINGand no instance isFAILEDor transitioning, and transitioning includesSTOPPED. The session in #1686 stopped and restarted its containers mid-way, which fits, but it isn't confirmed. HenceRefs, notCloses: the root cause still needs the central-manager logs.💡 Solution
A refused change now says why, on the page, and Studio only sends a domain change central manager will accept, judged by a fresh read:
add, Unbind passesremove. The mutation re-reads the cluster and the organization's domains, and derives the new list from that read. A domain another tab or member bound meanwhile is kept.RUNNING, or an instance isFAILEDor transitioning (central manager'sTRANSITIONING_INSTANCE_STATES,STOPPEDandUPDATEDincluded). The cluster is self-managed. A domain the change touches is missing, unverified, or bound to another cluster, the cluster's existing ones included. It refuses rather than drops: central manager unbinds whatever the new list leaves out without checking which cluster holds it (updateCluster.js:199-205), so dropping a domain bound elsewhere would unbind it there. A refusal over a domain the cluster already lists points at support, because nothing on the page can clear it.For example, a bind on a cluster with a stopped member now reads Instance node-1.example.com is STOPPED. This cluster's domains can change once every instance is running. and sends nothing. A refusal Studio can't foresee reads The domain change didn't go through: Cluster has an instance that is not RUNNING (…). Resolve the instance before the update.
⚖️ Alternatives
clusterId, accept deltas): that is where the invariant belongs, and it is filed as HarperFast/central-manager#937. This task was Studio-only, and Studio has to show whatever central manager refuses with anyway.🔧 Changes
describeDomainChangeRefusal.ts(new):describeClusterNotChangeablemirrorsassertClusterMutableand its status list, citing the source.describeUnsendableDomainChangechecks every held, added and removed domain, plus the self-managed case.setDomainIdsOnCluster.ts: takesadd/remove, reads the cluster and domains fresh, and writes them into the cache. It reads with the plain fetchers rather than through the query cache, whose error handler would toast a failed read a second time. It then refuses with aDomainChangeRefusedError, or derives the list and sends it. It skips the global toast andconsole.errors request failures at mutation level. Unbinding one custom domain no longer turns off certificates for the others #1801's cache write is removed.getOrganizationDomains.ts: exports the fetcher.Management.tsx: one mutation observer for Bind and Unbind. Unbind moves here from the table. The observer's error renders throughSubmitErrorMessage, only for the cluster it was about, with Studio's refusals shown as written and anything else prefixed. Bind passes intent; its old cluster-status toast is replaced by the mutation's check. Invalidations use the active query client.tableDefinition.tsx: takesonUnbindClickinstead of owning a second mutation, and wires it to the button; delete's invalidation uses the active query client.useUpdateCluster.ts: the edit mutation skips the global toast and reports its failure at mutation level.ClusterForm.tsxpasses the edit's failure to the details/version footer inClusterDetails.tsxand the billing footer inClusterBilling.tsx, which render it beside the submit button. Create is unchanged.src/features/cluster/DESIGN.md,DESIGN.md: why a domain change is built and checked from a fresh read, and the index line.✅ Verification
End-to-end route: not observable end-to-end here. It needs a central manager and a cluster with a stopped or transitioning member, and a bind changes real infrastructure. Verified at the request boundary against a mocked
apiClient, with the test client wired to the app's own error handlers. The copied status list is only proven against central manager's source (cited in the code), not against a running one; a staging pass over Bind, Unbind and edit on such a cluster would close that. After deploy,@type:resource @resource.method:PUT @resource.url:*/Cluster/*4xx counts should fall, since a refusal no longer sends the request.Management.test.tsx, new "a domain change central manager would refuse" block:FAILED,STOPPEDorUPDATED(the caseisRunninggets wrong), or while the cluster isn't running; a change still goes through when every instance is running or gone;Management.test.tsx, #1801's failed re-read test now asserts the change is refused, not sent.describeDomainChangeRefusal.test.ts(new): every mirrored status,FAILED, an unknown status passing through, and each domain case (missing, unverified, foreign-bound; held, added and removed; self-managed; adopting a domain bound here that the cluster doesn't list).ClusterForm.test.tsx(new): a refused edit's reason stays beside the button with no toast and one RUM report, and clears on resubmit; the billing footer shows it too.origin/claude/1800-bind-only-live-selectionsources. No inline message is ever rendered, and for the concurrent-writer case the PUT carries['dom-a', 'dom-c'], dropping the domain bound meanwhile. The negative control (all instances running) passes on both. Every guard was mutation-checked, and its test goes red without it: the fresh cluster read, the mutability check, the domain check, both toast opt-outs, both RUM reports, the same-cluster filter,UPDATEDin the list, the edit footer, and reading around the query cache.npx vitest run399 files / 3,738 passed (exit 0),npx tsc -bexit 0,npx oxlint --format stylish .exit 0,npx dprint checkexit 0, and again by the pre-commit hook.Pre-push review: a planning round (codex; verdict
better-alternative-exists, adopted as above) and two full rounds (codex graded, gemini). The Harper-domain adjudicator failed on auth and the Cursor legs failed on the 1Password SSH agent every round, so no round converged. Round 1 found that a failed fresh read went through the query cache's toast handler and was reported twice (fixed: plain fetchers, and the test client now mirrors the app's handler). In round 2 codex ruled every earlier finding resolved and raised only comment nits. I rejected:activeQueryClientis never declared inuseDataTableColumns", a blocker (gemini, round 2): it's declared attableDefinition.tsx:37, added in Bind on the Domains page sends only the domains still offered for binding #1840 and so outside this diff;tsc -band the tests that render the table pass;[orgId]is a prefix of[orgId, 'domains']. I switched the moved lines to the active client anyway, for testability;Also filed: HarperFast/central-manager#937 (P2, Bug):
updateCluster's domain branch can unbind another cluster's domain, strand a half-bound one, or throw on a deleted id. #1843 (P3, Bug): the Domains table's Remove tooltip never opens, becauseTooltipTrigger asChildwraps a fragment. It's pre-existing, and it's the React warning in these test runs.🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.
Related PRs: #1801 overlaps (stack base; this PR replaces its whole-list cache write), #1802 overlaps (stack base), #1840 overlaps (this PR's base), #1842 overlaps (both add ClusterForm.test.tsx; whichever lands second merges the file)
Complexity: complicated
Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-grok(no-receipt),cursor-composer(no-receipt),domain(auth),cursor-muse(no-receipt); declined=cursor-kimi; rounds=2; full=1 @ ee2b5ba
Review-Attention: study ~9m (raised: degraded review) @ ee2b5ba