Three places where the product said one thing and did another.
**Nothing could restart a virtual machine.** ProxmoxClient had startVm and no
way to stop anything, so `restart_required_since` — set by every plan change
that grows a running guest — could only ever be cleared by a resize step that
happened to find the machine stopped, which nothing in the product could
arrange. A paid upgrade's cores and RAM could stay unreached for the life of the
contract, and the cloud page's "Neu starten" was a toast.
There is now a `restart` pipeline: shutdown, start, wait for the agent, confirm.
A shutdown and a start rather than a reboot, because only a cold boot makes qemu
read the VM definition again — a reset would take the machine round and bring it
back exactly as small as it was. The shutdown is a REQUEST, bounded at ten
minutes, and there is no escalation: ProxmoxClient deliberately does not expose
Proxmox's forceStop, because the guest is a Nextcloud and cutting power to a
database mid-write to apply a CPU change trades an inconvenience for a restore
from backup. A guest that ignores ACPI for ten minutes fails the run instead, and
a person decides what happens to it.
`restart_required_since` is cleared by the last step, from what the RUNNING guest
reports (`cpus`/`maxmem`) — not by the button, and not by the run merely
finishing. A machine that comes back smaller than it was sold fails the run.
Started by App\Actions\RestartInstance, which re-checks authorisation itself
rather than trusting a hidden button: an operator holding the new
`instances.restart` may restart anything, a portal user their own machine and
nothing else. Refused while another run is in flight against the same order.
Confirmed in a modal on both sides (R23), addressed by uuid (R11).
**Existing instances had no storage quota.** ApplyStorageQuota reaches new and
changed machines; everything built before it kept the whole disk whatever it had
paid for, and nothing recorded which was which. `instances.quota_applied_gb` is
now what the guest was actually told, written by the step only after the occ call
succeeds — so "sold" and "enforced" are separate facts and can be compared.
`clupilot:apply-quotas` sweeps the estate: one `quota` run per instance through
the same step the pipelines use, skipping what it should and saying why, a no-op
once an allowance is enforced, and inert under --dry-run. Deliberately NOT
scheduled — a nightly sweep would quietly cover for the pipeline step the day it
stopped working, which is how the original hole survived this long.
**A Traefik router was never torn down.** TraefikWriter::remove() had no caller
anywhere. What was missing was the moment: ConfirmCancelPackage wrote a date into
`service_ends_at` and nothing ever went back to it, so every route this platform
ever wrote was written for good — pointing at a guest address the host is free to
reassign. `clupilot:end-due-services` (hourly) keeps that appointment through
App\Actions\EndInstanceService. "Ended" means cancellation_scheduled AND
`service_ends_at` passed, never the day a cancellation is scheduled: that
customer has paid to the end of the term and is working in it. The DNS record
goes with the route — it is in our zone, pointing at a host that serves other
people, which is the shape of every subdomain takeover there has ever been. The
customer's own domain is in their zone and is not ours to touch; the virtual
machine is left alone, because the cancellation flow promises a data export first
and destroying disks is not this change's decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PlanChange could say what a move would cost and whether it was allowed, and
that was all it could do. Billing::purchase() wrote an upgrade order and
nothing ever consumed it: same snapshot, same machine, same quota. A customer
could pay for a bigger package and receive nothing.
ApplyPlanChange is now the single place a change lands — it moves the contract
onto the target's current version, writes one register row, settles the custom
domain, and starts a run that resizes the machine. Applying the same order
twice is a no-op, enforced by a unique event key rather than by a check two
callers could both pass.
Two things a plan change must not do, and now does not. A disk is never shrunk
— Proxmox cannot, so the QUOTA shrinks instead, which is what was sold anyway.
And a live machine is never rebooted as a side effect: cores and RAM are
written, and where they need a restart the instance says so where an operator
and the customer can both see it.
The storage allowance also joins the build pipeline. ApplyStorageQuota was
written for plan changes and ran only there, so a brand-new customer still got
no quota at all — quota_gb reached the instance row and stopped, and every
package delivered the whole disk. The test guarding that pipeline only counted
its steps, which is how a list missing the one step that makes a package's
storage real stayed green for its whole life. It now names the step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IssueInvoice now rejects free-grant orders before it will consume an invoice
number for them — a full gift produces no invoice at all, while a discounted
grant still charges something and is invoiced exactly like an ordinary sale.
Keyed on the linked subscription/add-on's own provenance, not on the amount
being zero, so a genuinely fully-discounted Stripe checkout is unaffected.
Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count
— a gift at 0 must not drag the average down — and shows their number as its
own KPI instead of folding it in, since the owner measures himself on it.
The portal (Cloud, Billing) shows a granted package or module without a
price at all: not "free", not struck through, just the service, so a later
conversion to paid does not read as a negotiation.
Availability is now counted from our own monitoring answers. Uptime Kuma knows
the figure, but the REST bridge in front of it only answers "healthy, yes or
no", and waiting for the bridge to grow an endpoint would have left the panel
without the number indefinitely. The sync already asks on a schedule; counting
the answers gives a percentage that is ours and explainable.
An unanswered check is not a failed one — the counter only moves when a real
answer arrived, so our monitoring going down does not become the customer's
outage on the one figure they are asked to trust. An instance nobody has ever
checked shows no availability rather than 100 %.
The downgrade path existed nowhere. Going down is not the mirror of going up:
it can ask an instance to hold more than the target plan allows. Smaller plans
are now listed even when they cannot be taken, with the obstacle in the
customer's own numbers — "you have 31 users, this package allows 10" — because
a greyed-out button that does not name what is in the way is the thing people
ring about. Storage is checked against the last real reading rather than the
contractual allowance, or anyone who once bought a large plan could never leave
it. The limit is re-checked in the action: a rule enforced only in markup is
not enforced.
And the smaller corrections from this round:
- The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP
and MariaDB versions, which tell a tenant nothing they can act on.
- "EU — Serverstandort im Angebot festgehalten" under a label already reading
"Serverstandort" was two sentences saying nothing. It is the country.
- Support promised a reply "binnen 4 Std." with nothing behind it. What is true
everywhere else is an answer on the same working day.
- The actions column appeared even when no row in the table had an action.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The breadcrumb said Übersicht on every page. The sidebar and the breadcrumb
each built their own copy of the structure, so the layout had no way to know
which entry was current and fell back to the first one. Navigation::portal()
and ::console() are now the single definition, and currentLabel() matches on the
route name — which matters for the console, whose PATH changes between
host-bound and fallback mode while its route names do not.
The datacentre name is out of everything a customer sees, for the third time
and now at the source rather than in a template. Falkenstein is how an
operator places an instance; a customer's processing record names the
jurisdiction, and putting the building in customer copy means editing that copy
every time the estate grows.
Also swept the views onto the new scale: --text-faint is 2.8:1 and a decoration
token, but it was carrying table headers, hints and timestamps — text people
have to read. All of it moved to --muted, which is 5.0:1 and passes AA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The console was a second product. admin-tokens.css held a complete dark set —
its own surfaces, its own orange, its own status triad — so every shared
component had to work in two worlds, and the two halves of the application
drifted apart in ways nobody could see from inside either one. That file now
carries density and nothing else: smaller type, tighter rows, tighter corners.
Colour comes from one place.
The tokens themselves are rebuilt around what the redesign settled on:
- Radii scaled to object size (9/11/16/22) instead of one value everywhere,
which is what made the interface read as a design-system specification
rather than as a product.
- Warm, broad, low shadows. A neutral grey drop shadow belongs to a different
design language and reads as cold on this ground.
- --accent-press, which several rules already referenced and nothing defined.
An undefined var() does not make a browser skip the declaration; it computes
to , and for background-color that is transparent — the accent button
lost its fill on hover and left white text on white.
- No serif. A serif headline is what made every page read as a document, which
is the impression the whole redesign exists to remove. --font-serif now
points at the sans so nothing breaks while call sites are cleaned up.
The two shells are one shell with two configurations, and both finally have a
mobile navigation: the sidebar used to simply disappear below 900px, leaving no
way to move around the application at all on a phone.
The customer dashboard is bound to real records — instance, seats, traffic,
backups, contract — instead of the fixtures it shipped with. Where something is
not measured, storage consumption, it says what was contractually agreed rather
than inventing a figure. This is the sheet a customer forwards to their auditor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- ResolvesCustomer trait replaces 5 duplicated customer() lookups; portal actions
now SAY why nothing happened (operator accounts have no Customer) instead of
returning silently — the actual cause of 'dead buttons', '0/0 seats' and
'settings not saving' when signed in as admin
- portal layout: explicit notice for a login without a linked customer
- /cloud: per-instance maintenance badge + window details; seat note 'owner = seat 1'
- maintenance form: sectioned card, hints, placeholder, styled datetime fields,
grouped host picker with per-datacenter select-all + selection counter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- plans gain seats + performance class; customer views show storage/seats/
performance/features, no raw vCPU/RAM (admin keeps specs)
- billing: scoped wire:target + wire:key so one purchase no longer spins all
- chart island: line-colour->transparent gradient for filled line datasets;
MRR / backups / invoices bar charts converted to gradient line
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>