Surface grant expiry stage in the cluster UI, and route a shut-down cluster to upgrade - #1657
Surface grant expiry stage in the cluster UI, and route a shut-down cluster to upgrade#1657DavidCockerill wants to merge 21 commits into
Conversation
…er to upgrade Studio showed a cluster's runtime status but nothing about its commercial window, so a trial gave no warning before it stopped and no route back up afterwards. central-manager now returns a customer-safe `grant` projection on both the cluster read and the org listing, so the card and the cluster pages can say where a cluster sits in its expiry timeline. `describeGrantExpiry` is the single decision point, so the badge and the banner cannot disagree. It reads `isActive` rather than `status`: an ACTIVE row past its endsAt is not live until the expiry runner stamps it, so status alone reports a withdrawn cluster as healthy. Once service is withdrawn the only way back up is buying a plan — the start gate refuses a suspended cluster with a 402, superusers included. So the Start actions are hidden for that cluster and the card, its menu and the banner all route to the plan editor instead. Also fixes /scaling announcing completion too early. A trial->paid conversion now reaches RUNNING before the server applies the plan, so status alone flipped the screen to "All done! Your cluster finished updating" while the plan change was still in flight. Completion is `RUNNING` plus a grant that is no longer `conversion-pending`, which is exactly when the provisional grant is replaced. Refs central-manager#583
The banner only offered a route to the plan editor once service had already been withdrawn, which is the one moment it is too late to be useful. The warning stages now carry the same CTA, so a customer can act while the cluster is still running. `offerUpgrade` is separate from `needsUpgrade` on purpose: the first is "a plan is worth buying", the second is "the cluster is down and only a plan revives it". They differ exactly at WARNED/FINAL_WARNING/GRACE, and only the second should hide the Start actions or re-route the card. The CTA carries `?upgrade=hobbyist`, and the editor opens on that plan rather than the one the cluster runs today — landing on the expiring trial would make the CTA a no-op. Hobbyist prescribes a single shape: colocated, one region, one distribution. The deployment, region and distribution controls are locked while it is selected, and the add/remove-region buttons are hidden, because offering those choices only produces a request central-manager refuses — it freezes the region set while a trial or level-0 plan is on the cluster. The lock is driven by the live form value, so switching to another plan restores the controls. The plan id is pinned in constants rather than matched on a catalogue description: studio otherwise picks plans by description, but this one is named in the flow itself. Refs central-manager#583
…cted one The lock keyed off Hobbyist being selected, which was wrong in both directions. Too strict after: central-manager's freeze applies while a trial or level-0 plan is on the cluster, and Hobbyist is neither — so a Hobbyist customer may add or move regions, but the UI refused to let them. Too loose before: converting a trial to Level 1 is frozen just the same, and that path had no lock at all, so a region change went to the server and came back a 400. Keying on the cluster's current plans matches the server rule exactly, and the freeze lifts by itself once the paid plan lands. The deployment picker stays tied to Hobbyist, which really is colocated-only. Also says why the control is locked rather than presenting a dead select. Refs central-manager#583
The "Premium" badge marked regions no base-level plan could reach, where base meant the lowest planLevel for the deployment. Hobbyist is planLevel 0 — the same as the trial — so a $20 plan's reach defined what counted as free. That is about to matter: Hobbyist's region cap is being lifted in the plan catalogue, since price already scales with distribution (priceUsd * instanceCount / 2, so $20 in us-1 and $100 in us-5) and the cap excluded regions like us-ne-1 that cost exactly what us-1 costs. Under the old rule, lifting it would have made every region reachable by a "base" plan and silently erased every Premium badge — leaving trial users with regions disabled and no explanation of why. Keyed on priceUsd instead, the badge keeps meaning "costs money to reach": the free trial's cap still defines it, and Hobbyist counts as paid. Both the all-free and all-paid cases badge nothing, which is what they mean. Refs central-manager#583
central-manager's scopeViolations no longer resolves a region family and compares purchasedBlockMultiplier — it is a plain includes() on the id list (grant-scoping a3e6bbaa). Recording it here because the old wording would have had a scope-aware plan picker infer sizes that are not covered. Refs central-manager#583
`regionSetFrozen` was placed directly under the search-param hooks, two lines above the `useRouteContext` destructure it reads `cluster` from. That is a temporal dead zone: the whole cluster editor threw "Cannot access 'cluster' before initialization" on load, so /edit was a Component Error page. Nothing caught it — `tsc --noEmit` and oxlint both passed on the broken tree, and no test renders the editor, so 2733 green tests said nothing. Found by opening the page. Refs central-manager#583
The performance picker listed every plan for the deployment, so editing a cluster offered "30-day trial (1K read/min)" as a target. A trial is granted at signup and cannot be re-entered — picking it is a change central-manager would refuse, and on the upgrade path it sits directly above the plan the customer came there to leave. Free managed plans are now filtered out when editing an existing cluster. Keyed on priceUsd rather than a plan id, so a catalogue rename cannot quietly re-admit one. Self-hosted is exempt: its free tier is a real ongoing plan. The cluster's own plan always stays listed, or a customer still on the trial would open the editor to a picker with nothing selected. Creating a cluster is untouched — there the free plan is the offer. Refs central-manager#583
The banner told a cluster in its grace period that it had been stopped, while it was still running and serving traffic. GRACE runs `expireGrant` but not `stopCluster` — the shutdown is a separate stage seven days later — so the grant is inactive while the cluster is up. The `!isActive` branch ran ahead of the stage switch and swallowed GRACE, which made the switch's own GRACE arm dead code. It is answered first now, and the dead arm is gone. The same assumption was wrong more generally: the runner expiring a grant and stopping the cluster are separate acts, and the runner can lag. So the copy no longer infers the stop from the grant at all — it reads whether the cluster is actually down (`status`, `suspendedReason`) and otherwise says the cluster will be stopped rather than that it has been. Found by David on clu-6xm9wlx1dfnwzfop. Refs central-manager#583
…n ending Not one of them can succeed. The start gate refuses a suspended cluster with a 402 for everyone, superusers included, and there is nothing running to restart or stop — so the group offered a menu of guaranteed failures. Hiding only Start left the "Container" heading over an empty section, which reads as broken rather than deliberate, so the whole group goes. Applied to both surfaces: the card's menu and ClusterStateMenu on the cluster page. Terminate stays in both — leaving is always allowed. Deliberately narrow: it keys on the cluster being stopped AND its plan having ended, so a cluster whose grant lapsed but which is still running keeps Stop and Restart, which central-manager does allow on the way down. Also stops the price display floating over the expiry banner. The price is absolutely positioned against the cluster layout's `relative` box, and the banner was rendered at the top of that same box. The banner now sits above it, with matching padding so the price stays flush to the content edge. And makes the suite committable: an inert WebSocket stub in vitest setup. Anything mounting the app shell starts the SystemStatus socket, which under jsdom reaches undici 8's WebSocket and throws a cross-realm Event TypeError as an unhandled error — tests all pass, vitest exits non-zero, the hook rejects. It was racy, so committing here was a coin flip. Distinct from the undici-7 pin in AGENTS.md, which is a missing module rather than a realm mismatch. Refs central-manager#583
It cost a run of pre-commit rejections on a tree whose tests were all passing, and the failure mode (green tests, non-zero exit, racy) does not lead anyone to the cause on its own. Refs central-manager#583
…t warning on The settings-page banner is where the full story lives, but a developer building against their cluster may never open those pages between signup and shutdown — so the entire warning sequence could play out somewhere nobody was looking, and the first they knew was a stopped cluster. Two deliberate differences from the settings banner, because this sits above work rather than beside it: The first warning is excluded. It runs for a week, and a banner shown for a week on a surface someone is actively using becomes furniture — taking the final warning down with it. `isUrgentExpiry` names that rule and keeps it with the other expiry logic, so it can be argued with in one place. It is a line, not a block: no heading, no button, just the same sentence and a link. A full Alert is right above a settings page and wrong above a data grid. Not dismissible. At this point there are at most two days left, and a trial that stops without warning is the outcome this work exists to prevent. Copy comes from describeGrantExpiry like the other two surfaces, so the three cannot drift. Refs central-manager#583
…ast two days David's call, overriding the gate I shipped in the previous commit. The argument for holding back — a week-long banner on a working surface becomes furniture — loses to the simpler one: the whole point of putting it here is that the settings pages may never be opened, and starting at the final warning leaves a customer two days to act on a plan change they have not thought about. `isUrgentExpiry` is renamed `isExpiryWarning`, because with the first stage included the rule is no longer about urgency: it is every expiry stage except a conversion in flight, which is progress rather than a warning. Leaving the old name over the new behaviour would have been the misleading half of the change. Refs central-manager#583
The third surface carrying container actions, missed when the card menu and ClusterStateMenu were done. An instance stopped by trial expiry still offered Start and Start in safe mode, both of which the start gate refuses with a 402. Scoped to the stopped-instance actions rather than the whole group: a still running instance keeps Restart and Stop, which central-manager allows on the way down. That matches the rule used on the other two surfaces. The hook takes only an instance, so it reads the cluster from the cached query its callers' route already loads, rather than threading a flag through InstanceActionsMenu and InstanceRowContextMenu. Found by David. Refs central-manager#583
…data with it "The cluster has been stopped. Choose a paid plan to start it again" reads as reversible and open-ended. It is neither: a consumer trial deletes the cluster 14 days after shutdown and enterprise-grace 21, taking the data. That is the actual stake and the banner never mentioned it. Deliberately no date. The day counts live in central-manager's policy table, whose own comment says changing the timeline is a change to that file only — a copy here would go on confidently showing a wrong deletion date for a destructive action. Studio keys on which policies end in `deleteCluster` instead, which is stable, and an unrecognised policy simply makes no claim. A precise date needs the next-stage-and-due-date projection central-manager does not expose yet; `stageDueAt` is computed per request and never stored. Guarded so it stays true: a lapsed purchased grant carries no policy and nothing deletes it, so that case keeps the old wording. Refs central-manager#583
…edule The banner said the cluster and its data would be deleted but could not say when, because studio had no access to the stage offsets. central-manager now returns the resolved schedule on the grant projection, so it reads "deleted on 15 September" — the difference between a warning someone acts on and one they scroll past. The policy-name guess this replaces is gone. Rather than a local set of which policies end in deletion, the copy reads the DELETED entry off the schedule and uses its date. That removes the last place studio encoded anything about the expiry timeline, and it drops an assumption that was wrong anyway: stage lists differ per policy — enterprise-grace has five stages, consumer-trial four — and most trial grants sit on enterprise-grace, so `source: 'trial'` never implied the consumer timeline. The schedule is computed per read and never stored, so a policy edit moves these dates with no migration. Don't cache them. Undated wording is kept for the cases with no schedule: a forever grant, an unparseable endsAt, or a lapsed purchased grant that nothing deletes. Refs central-manager#583
`upgradeHref && update && {…}` puts `""` into the union when upgradeHref is
undefined, and excludeFalsy doesn't narrow the string out, so EntityMenuItem[]
no longer typechecks and `npm run build` fails.
It went unnoticed because the root tsconfig is `{"files": []}` with project
references: plain `tsc --noEmit` compiles nothing and reports a false green.
`tsc -b --noEmit` is the command that actually checks this repo.
Refs central-manager#583
…eal coverage
A /review-gate run returned FAIL with four blocking findings. This fixes those,
eight of the should-fix items, and the coverage gap that let them through.
BLOCKING
A failed conversion rendered as a permanent spinner. Nothing ever rewrites
`expiryPolicy`, so a lapsed provisional grant still reads `conversion-pending`
forever; testing that before `isActive` meant the terminal state of a failed
upgrade was "Upgrading", with no CTA and a Start that 402s. The live check now
gates it, and the switch's unreachable SHUTDOWN/DELETED arms are removed.
The region lock disabled the control but not the value. Three effects wrote
through it, so a mutated region set was submitted and refused; one of them
blanked a frozen region, leaving the form permanently unsatisfiable because
nothing could put a value back. All three now stand down when frozen.
The card's "Choose a Plan" omitted `?upgrade=hobbyist`, so the primary route
off the cluster list opened the editor on the expired trial with submit
disabled. EntityMenuItem gained `search`, since a query string inside `to` is
not parsed by the router.
`upgradeHref &&` put `""` into the menu-item union and broke `npm run build`.
It went unseen because the root tsconfig is `{"files": []}` with project
references: `tsc --noEmit` compiles nothing. `tsc -b --noEmit` is the real check.
HONEST COPY
Container actions now key on `isStartBlockedByPlan`, mirroring the server's own
`clusterStartBlockedReason` instead of the grant's state — the old predicate was
wrong in both directions, hiding controls the API would have accepted and
leaving a Start the API refuses.
No deletion date is quoted for a revoked grant (the runner skips those, so the
schedule is never walked) or for a date already past (that means the runner is
behind, and naming a stale date reads as a system that has lost track). Nothing
claims a past ending for an absent or future date — reachable via a revoked
forever-grant and via a future startsAt. A grace period no longer asserts the
cluster is running without checking, and no longer sells a $20 self-serve plan
to an account with a negotiated enterprise agreement. Countdowns compare
calendar days: rounding up put two hours before midnight into "ends tomorrow",
inside the window where the next stage stops the cluster.
"Premium" means the same thing in both halves of the form; the Hobbyist
deployment lock no longer fires on the create page; one optional chain that
threw on `plans: []`.
COVERAGE
The editor had no test that mounted it. Four of five mutants had survived the
whole suite. `ClusterDetails.test.tsx` mounts it and kills three; the fourth was
`Scaling.tsx` reverting wholesale, which survived because `Scaling.test.tsx`
mocked a cluster with no `grant` — so every assertion there collapsed to
`status === 'RUNNING'` and pinned the pre-branch behaviour. All four are
mutation-verified dead.
Radix renders a Select's options only while open, and in jsdom ArrowDown opens
one where pointerDown does not — so an "option is absent" assertion passes
whether or not the filter works. That is how the plan filter survived. The
polyfills Radix needs are in test setup, guarded for the node-environment tests
that have no DOM, and the trap is written up in AGENTS.md.
NOT FIXED, deliberately: the scope-aware plan picker (grant.allowedPlanIds is
declared and unread) is a feature slice, not a fix; `regionFrozenPlanIds`
duplicates a CM constant with nothing detecting drift, and removing it needs CM
to expose free-restricted-ness; the Usage page's "renews <date>" contradicts the
new banner but predates this branch.
Refs central-manager#583
A scoped bug-hunt on the previous commit confirmed four, three of them introduced by that commit. Its verdict on the fourth risk area held: the jsdom polyfills change no existing test's behaviour, measured by removing them and running the suite. Hiding the container group on "start is blocked" took Stop and Restart from a RUNNING cluster. central-manager writes `suspendedReason` BEFORE stopping, and defers to a later pass if the cluster is mid-transition, so suspended-and-still- up is a persistent state — and its own gate reads `action !== 'stop'`, admitting stop unconditionally. The customer's cluster was up, billing, and offered nothing but Terminate. Only the Start actions are blocked now; the group is hidden solely when it would be a heading over nothing. Guarding the region effects removed the blanking that used to make the form invalid, and nothing else objected: `refineZod` computed the region but raised no issue when it failed to resolve, so submit stayed enabled and the click threw before the toast — a dead button, permanently. Validation now refuses a region that no longer resolves, and the submit path no longer indexes blind. `&& !stopped` on the grace arm sent a stopped grace-period cluster into the withdrawn copy, restoring the $20 self-serve CTA the same commit had removed for being the wrong answer to an enterprise agreement — and advising a plan purchase where the server would have accepted a plain Start. Grace now reads the status without leaving its own arm. The upgrade CTA still landed on a disabled submit: preselecting the target plan makes it the form's default, so `isDirty` is false. That gate predates this work, but the CTA routes into it, so the blocking finding was not closed. Same escape hatch as the existing version resubmit. Also, per David: the trial no longer lists once the selection has moved off it. Keeping the current plan listed was to stop the picker opening blank, which only needs it while it is the tier being shown — after the CTA preselects Hobbyist, listing the trial just offers a move back onto a plan nobody can re-enter. Every fix is mutation-verified: reverting each one fails a test. The container gate needed a new ClusterStateMenu suite, since reverting it passed 112. Refs central-manager#583
There was a problem hiding this comment.
Code Review
This pull request introduces a robust mechanism for handling cluster plan and grant expiry, including UI banners, status strips, and restricted container actions for expired plans. It also adds comprehensive tests and test setup polyfills to prevent JSDOM and WebSocket-related test failures. The review feedback highlights a timezone-dependency issue in date calculations that should be resolved by using UTC methods, suggests safer handling of optional parameters in the expiry strip, and recommends avoiding non-null assertions in the cluster form.
The generated RegionPlan type requires planId, so the assertion asserted nothing while telling readers the field might be missing. Flagged by Gemini on the PR; its suggested runtime guard is equally unnecessary for the same reason. Refs central-manager#583
central-manager now marks a conversion on the cluster: APPLYING while it starts the cluster and lands the plan, FAILED from the single catch every failure path passes through, null once the plan lands. Before this, a failed conversion was indistinguishable from a slow one for up to four hours — the provisional grant's whole window — and the UI could only say "still finishing". The marker is the direct signal; the conversion-pending grant check stays as the belt for a lost write, because the marker is best-effort: FAILED is reliable, a missing marker on a still-live provisional grant means "probably still applying", and a lapsed one still falls through to the honest withdrawn copy rather than reading as progress. FAILED preempts both the progress copy and the plain-expiry copy, on every surface via describeGrantExpiry, and /scaling gets an explicit failure state instead of a spinner. The copy promises "nothing was charged" flatly — payment is only verified up front and blocks mint inside the plan change itself, so a failed conversion cannot have billed anything. The Scaling test's mocked grant gains the isActive the real projection always sends: the belt rightly demands it true, since a lapsed provisional grant must read as ended, not as still applying. Refs central-manager#583
|
Can I have more pictures? 😆 |
|
(Screenshots.) |
| ].filter(Boolean) as SubNavItem[]; | ||
|
|
||
| return ( | ||
| // `relative` so absolutely-positioned page furniture (the cluster editor's price display) anchors |
There was a problem hiding this comment.
I'm going to need to be able to see this to verify it hasn't borked everything, but I can't do that without also having central-manager and various things setup and running locally on your branch, too. Bit of a catch 22.
Refs central-manager#583 — Pricing Updates
Companion to the central-manager epic
This is the customer-facing half of the pricing work tracked by
central-manager#583 — calendar-month
blocks, prorated overage, and the 30-day trial converting to a $20/mo Hobbyist plan.
The split is clean: central-manager owns the entitlement and billing engine — grants, the expiry
policy timeline, the runner that walks a cluster through warning, shutdown and deletion, and the
conversion that takes payment and applies the new plan. This PR owns every surface a customer or
Fabric admin actually sees of it. Studio makes no commercial decisions and encodes no timing; it
renders what the server says and offers the routes the server will accept.
Both repos use the same branch shape: feature branches into
epic/pricing-updates, then one epic PR.This targets studio's epic branch, so closing keywords are deliberately absent — the epic PR into
stagecarries those.That epic lists "a customer can see their block usage and expiration in Studio, and understand an
overage charge without asking" among its success criteria. This is the warning-and-conversion part of
that; usage and overage transparency are not in this PR.
The problem
Studio shows a cluster's runtime status but nothing about its commercial window. A trial gives no warning before it stops, and once stopped there is no route back up — the sidebar still offers "Scaling", the card offers nothing, and a bare start is refused. This adds the customer-facing half of the pricing epic.
🧊 In plain terms
If your trial is about to end, Harper now tells you — on the cluster list, on the cluster's own pages, and inside the cluster while you are working. When it has ended, it tells you the cluster will be deleted and on what date, and gives you a button that lands on the plan you would actually buy. Buttons that could not possibly work — Start on a cluster whose plan has ended — are gone rather than left there to fail.
What it adds
Ends in 5 daysthroughPlan endedPlus: a shut-down cluster routes to the plan editor; container actions that the server would refuse are withheld; the upgrade CTA opens on Hobbyist; the region set locks during conversion, matching the server's own freeze; and
/scalingno longer announces "All done!" while a conversion is still applying.Design notes
One decision point. All three surfaces read
describeGrantExpiry, so their copy cannot drift apart.Studio encodes nothing about expiry timing. No day offsets, no stage counts, no policy-to-behaviour mapping. Every date comes from the
timelinecentral-manager computes per read, so editing a policy moves what customers see with no change here. The deletion date is read off the schedule's ownDELETEDentry rather than inferred from the policy name — stage lists differ per policy, and most trial grants sit onenterprise-grace, notconsumer-trial.Gates mirror the server, not the grant.
isStartBlockedByPlanfollowsclusterStartBlockedReason: only start is refused, and only when the cluster carries asuspendedReasonwith no live grant. Stop and Restart stay — the server admitsstopunconditionally, and a suspended cluster can be RUNNING for a week under the enterprise policy.Nothing asserts a state it can read. Whether the cluster is stopped, whether a deletion date is still ahead, whether an ending is in the past — all read, not assumed.
Testing
330 → 332 test files. Two new suites mount the cluster editor and the cluster-actions menu, neither of which had any render coverage: a temporal-dead-zone crash shipped in the editor behind a fully green suite during this work.
Every behavioural fix here is mutation-verified — reverting it fails a test. That mattered: an earlier round had four of five mutants survive the entire suite.
Review
Cross-reviewed before opening, across two rounds.
Round one — the full pre-push pipeline over the whole branch: eleven legs. Two outside models (codex and Gemini) each read the diff cold with no author explanation, then again against the author's claims; five Claude specialists covered bug-hunting, mutation, runtime verification, scope and conventions; then one cross-examination round where each outside model attacked or defended every finding.
Verdict was FAIL — four blocking, twelve should-fix, eleven nits. The findings that mattered most:
{"files": []}with project references, so plaintsc --noEmitcompiled nothing./scalingchange could each be reverted without a single test failing. One test file that read like coverage of that screen mocked a cluster with no grant, so its assertions collapsed to the pre-branch behaviour.Cross-examination also killed two findings rather than only adding them — both outside models agreed they fell.
Round two — a scoped bug-hunt over the fixes themselves, which confirmed four more, three introduced by the fix commit. Worth naming, because they are the class of thing that hides well: hiding container actions took
Stopfrom a running suspended cluster, guarding the region effects left submit enabled but inert, and a&& !stoppedreinstated an upsell that had just been removed for being wrong.Every behavioural fix from both rounds is mutation-verified — reverting it fails a test. Where that required coverage that did not exist, it was written: the cluster editor and the cluster-actions menu had no test that mounted them at all.
Not included, deliberately
grant.allowedPlanIds/allowedRegionIdsare typed but unread, so an admin-scoped comp still offers plans the server will reject. A feature slice, not a fix.regionFrozenPlanIdsduplicates a central-manager constant with nothing detecting drift. Removing it needs CM to expose free-restricted-ness.renews <date>directly beneath the new banner's deletion date. Predates this branch; this branch is what puts both on one screen.Depends on the central-manager side shipping first
Everything here reads a
grantprojection that central-manager returns on a cluster — the governinggrant plus its computed expiry
timeline. That projection (grantView.js) is not yet on anydeployed central-manager, so this feature is dark until it lands: with no
granton the payloadevery surface renders nothing, which is the intended fallback rather than a failure.
So this can be reviewed and merged into studio's epic branch now, but the epic cannot reach
stageahead of the central-manager work.
— Claude (Fable 5)