Commit Graph

4 Commits (feature/host-bootstrap)

Author SHA1 Message Date
nexxo d76de5ada3 Restart a machine, enforce the quota that was sold, end a route that ended
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>
2026-07-29 18:28:28 +02:00
nexxo 6376f5e9fa One design system for every surface
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>
2026-07-27 16:03:47 +02:00
nexxo 32fa1028cc fix(portal): cloud status labels for all instance states; scale storage curve into the quota
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:16:16 +02:00
nexxo 254a7d46a0 feat(portal): Fortify auth + Login/2FA/Dashboard + component kit
Backend (Fortify):
- laravel/fortify with TOTP two-factor + recovery codes; User uses
  TwoFactorAuthenticatable; 2FA credentials hidden from serialization.
- Views off — pages are full-page class-based Livewire components (R1/R2);
  Fortify handles POST actions. Home redirect -> /dashboard. v1 scope: login +
  2FA only (no public register/reset/passkeys). Seeder gated to local/testing.

Component kit (Blade, token-based, a11y):
- button, input, checkbox, alert, card, badge, stat-tile, otp-input (Alpine,
  auto-advance/paste, -safe submit), progress-stepper, nav-item, icon
  (Lucide), plus layouts/portal-app app-shell (sidebar drawer + topbar + menu).

Screens (localized DE/EN, R16):
- Login (form -> login.store), Two-factor challenge (OTP + recovery fallback),
  Dashboard (KPI stat tiles, instance card, provisioning stepper fixtures,
  activity). Routes English (R13).

Tests + verification:
- Pest: 14 green (login ok/invalid/throttle, dashboard guard, component render).
- R12 browser (Puppeteer, prod assets): /, /login, /two-factor-challenge and
  the authenticated /dashboard all HTTP 200 with ZERO console errors; login
  flow verified end-to-end.
- Test isolation fixed (force test env over injected .env).
- Reviewed with Codex (R15): 4 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:20:25 +02:00