Skip to content

settings: give unsubscribed shards a reachable resize control - #51

Merged
max-tet merged 2 commits into
mainfrom
fix/clayde/trial-resize-confirm-button
Sep 2, 2026
Merged

max-tet merged 2 commits into
mainfrom
fix/clayde/trial-resize-confirm-button

Conversation

@ClaydeCode

Copy link
Copy Markdown
Contributor

Closes #49.

Cause

resizeShard() in src/views/Settings.vue was fully implemented and completely unreachable. grep -n resizeShard src/ returned exactly one line on main — its own definition. No template binding, no other call site.

The only action control the selection panel rendered was the PayPal revise container (<div ref="resizeButton">), and renderResizeButton() returns early unless resizeNeedsApproval — which is profile.billing_enabled && subscription && subscription.status === 'active', false on a trial shard. So the PayPal path declined to render and the free path had nothing to render. The panel was inert by construction: the copy said "Approve the new price to resize to XS", and the only thing to click was Cancel.

What changed

One file, src/views/Settings.vue.

Template. The selection panel now branches on resizeNeedsApproval:

  • subscribed: unchanged — same copy, same <div ref="resizeButton"> for the PayPal revise button.
  • unsubscribed: a Confirm button bound to resizeShard(), disabled while resize.waitingForRestart, and the copy drops the price clause — "Resize to XS. The shard restarts afterwards." There is no subscription, so there is no price to approve.

ref="resizeButton" now sits behind v-if="resizeNeedsApproval". Both renderResizeButton() and teardownResizeButton() already guard on this.$refs.resizeButton, so the ref being absent on the unsubscribed path is handled.

resizeShard(). The method now settles its own post-resize state. Details below, since this is the part that differs most between the two paths.

How the two paths now differ after a resize

The subscribed path leaves waitingForRestart set and relies on the hasPendingResize watcher: the PayPal UPDATED webhook promotes the price, clears pending_vm_size and resizes the VM, the interstitial poll picks the profile change up, and the watcher clears both waitingForRestart and selectedSize.

An unsubscribed resize never creates a pending_vm_size — the controller's POST /api/shards/self/resize calls begin_resize_shard() directly when shard.subscription_id is None and returns an empty ResizeResponse (freeshard-controller-backend/freeshard_controller/api/shards.py, read at origin/main). So that watcher can never fire for this path, and the method has to clear its own state:

  • on error — clear waitingForRestart, keep selectedSize. The panel stays up with the toast, so the user can retry or Cancel.
  • on success — clear selectedSize, deliberately leave waitingForRestart set, then navigate to /restart. The shard is going down; re-enabling the size buttons would let a second resize be fired at a VM that is being deallocated. Settings unmounts on navigation anyway, so the flag only matters in the case where the navigation does not happen — which is exactly the case where the guard is wanted.

The $router.replace('/restart') also moved out of the try. Awaiting it inside meant a rejected route transition (vue-router 3 rejects on aborted/redundant navigation) would have surfaced as "Error during resize" for a resize that had already succeeded. It is now .catch(() => {}), matching the idiom already used for the sub query cleanup in the same file.

Verification

Ran in a fresh clone of main with npm ci:

  • npx vue-cli-service lint --no-fix — "No lint errors found!"
  • npx vue-cli-service test:unit — 3 suites, 19 tests, all passed (none of them cover Settings.vue; the existing suites are store, pricing and paypal-sdk).
  • NODE_OPTIONS=--openssl-legacy-provider npx vue-cli-service build — "Build complete."

Not verified against a live shard. There is no component test for Settings.vue in this repo and I did not add one.

Uncertain / worth a second opinion

The controller refuses downsizing, so the issue's motivating scenario still fails. _check_resize_preconditions raises 409 "Resize must increase size: current is 's', requested 'xs'" when new_vm_size <= shard.vm_size, for subscribed and unsubscribed shards alike (api/shards.py at origin/main; VmSize orders XS < S < M < L < XL). The frontend's sizeIsAvailable() does not enforce that — it only checks the tier against max_vm_size — so a trial user on S can still select XS, and will now get a toast carrying that 409 detail instead of an inert panel.

That is a strict improvement over a dead end, but it does not deliver "a trial user who only needs XS can get there before subscribing" from the issue text. Reaching that needs a controller change to allow downsizing, which is out of scope here and in a different repo. An upsize on a trial shard (S → M, say) works with this PR. Tell me if you want the downsize case handled on the frontend in the meantime (grey out smaller sizes rather than let the request 409) or an issue opened against the controller.

Button label and layout. Confirm with variant="primary" size="sm" and an mr-2 so it does not sit flush against the Cancel link. Not styled beyond that, and #50 reworks these cards anyway.

Out of scope and untouched: #48 (no price shown on trial shards) and #50 (subscription/size/disk card rework).

resizeShard() implemented the immediate resize path for shards without a
subscription, but nothing ever called it: no template binding, no other
call site. The selection panel rendered only the PayPal revise container,
and renderResizeButton() returns early unless resizeNeedsApproval, so on a
trial shard the panel offered nothing but Cancel.

Render a Confirm button bound to resizeShard() when !resizeNeedsApproval,
and branch the copy — there is no price to approve without a subscription.

The immediate path now settles its own state. The subscribed path leaves
waitingForRestart set and lets the hasPendingResize watcher clear it once
the UPDATED webhook commits the resize. An unsubscribed resize never
creates a pending_vm_size, so that watcher cannot fire for it: on error the
method clears waitingForRestart itself and keeps the selection so the user
can retry or cancel, and on success it clears the selection and keeps
waitingForRestart set while handing over to the /restart interstitial, so
the size buttons cannot fire a second resize against a shard that is going
down. The navigation is no longer awaited inside the try — a rejected
route transition would otherwise be reported as a failed resize.

Refs #49
@ClaydeCode
ClaydeCode requested a review from max-tet September 1, 2026 14:42
The size buttons were gated only against max_vm_size, so every smaller
tier stayed selectable while the controller rejects any downsize with a
409. After the previous commit made the resize control reachable, that
turned an inert panel into an offer the system cannot honour.

The controller's rule comes from OVH: a classic flavor may only move to
one with a similar or bigger local disk, and ours run 25/50/50/100/200 GB
across xs..xl. Rather than restate that reasoning at the button, the card
now says a Shard can only be made larger, so the greyed-out tiers are
explained instead of merely disabled.
@ClaydeCode

Copy link
Copy Markdown
Contributor Author

Added the UI follow-through, 440e19e. The downsize question is now settled: it stays disallowed, and the frontend agrees with the backend instead of contradicting it.

What changed

sizeIsAvailable() was gated only against max_vm_size, excluding the current size but nothing below it. So every smaller tier stayed selectable while the controller rejects any downsize with a 409 — and after the first commit made the resize control reachable, that turned an inert panel into an offer the system cannot honour. It now requires the target index to be strictly greater than the current size:

const index = this.resize.sizes.indexOf(size);
return index <= this.resize.sizes.indexOf(profile.max_vm_size)
    && index > this.resize.sizes.indexOf(profile.vm_size);

The Size card also gains one muted line — "A Shard can only be made larger, never smaller." — so the greyed-out buttons are explained rather than just dead.

Why the backend refuses, for the record

Not arbitrary. OVH's guide states "Only upscaling is possible for classic models": a classic flavor may only move to one with a similar or bigger local disk, and only Flex instances (locked 50 GB) resize both ways. Our flavors are classic d2 and b3. Queried from the OVH API against our own project on 2026-09-01:

Tier Flavor vCore / RAM Local disk
xs d2-2 1 / 2 GB 25 GB
s d2-4 2 / 4 GB 50 GB
m d2-8 4 / 8 GB 50 GB
l b3-16 4 / 16 GB 100 GB
xl b3-32 8 / 32 GB 200 GB

s → xs is 50 GB → 25 GB, which OVH refuses. Full investigation in FreeshardBase/freeshard-controller#437; reference table in the vault at freeshard/vm-sizes.md.

Verified

Re-run on the branch after this commit, all actually executed:

  • npx vue-cli-service lint --no-fix — "No lint errors found!"
  • npx vue-cli-service test:unit — 3 suites, 19 tests, all pass
  • NODE_OPTIONS=--openssl-legacy-provider npx vue-cli-service build — "Build complete."

Still not exercised against a live shard.

Note for the reviewer

This makes the PR's behaviour honest, but it does not deliver the original motivation in #49 — a trial user on s still has no route to xs, because there is none. The remaining lever is what size trials start at, which is a controller-side decision and not this PR's business.

@max-tet
max-tet merged commit a8810d1 into main Sep 2, 2026
2 checks passed
@max-tet
max-tet deleted the fix/clayde/trial-resize-confirm-button branch September 2, 2026 07:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Trial shards: the resize flow dead-ends — resizeShard() exists but nothing ever calls it

2 participants