Commit Graph

18 Commits (dc35e5310f04aeeda0aa4cbacd2ab52943e4f703)

Author SHA1 Message Date
nexxo dc35e5310f Release v1.3.93 — die Seite konnte den Host gar nicht fragen
tests / pest (push) Failing after 9m23s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details
Auf echter Hardware blieb jede Messkachel leer, während der Host sichtbar
online war und vor zwei Minuten geantwortet hatte. Kein Zufall und kein
Aussetzer: ein Entwurfsfehler.

Nur der queue-provisioning-Container hängt im WireGuard-Netz — NET_ADMIN,
/dev/net/tun, das wireguard-Volume. Der app-Container, der die Konsole rendert,
hat überhaupt keine Route zu 10.66.0.x. Ich hatte den Proxmox-Aufruf in
render() gelegt, also in den einen Container, der die Management-Adresse nicht
erreichen kann. Ein Aufruf von dort konnte nie etwas anderes sein als eine
Zeitüberschreitung.

PingHosts schreibt dieselbe Regel seit Langem in seinen Kopf: "runs on the
provisioning queue, which is where the Proxmox credentials are usable." Ich
habe sie gelesen und nicht angewendet.

Verschlimmert hat es mein eigenes catch (Throwable): der Grund wurde
verschluckt, und die Kachel sagte "keine Messwerte" — ununterscheidbar davon,
dass der Host schweigt. Auf dem Testhost fiel nichts auf, weil TEST-NET
ohnehin nie antwortet.

Jetzt zwei Hälften:

- HostLoadSeries::collect() holt und legt ab, aus Jobs\CollectHostLoad auf der
  provisioning-Warteschlange, minütlich — der Takt, in dem Proxmox einen
  frischen Messwert schreibt. Ein Fehlschlag wird protokolliert, mit Host,
  Node und Grund.
- HostLoadSeries::forHost() liest nur aus dem Zwischenspeicher und öffnet nie
  eine Verbindung. Ein Test hält das mit Http::assertNothingSent() fest.

Der Eintrag lebt fünf Minuten bei minütlichem Sammeln: länger als der Takt,
damit ein ausgefallener Lauf keine Seite leert, die eine Sekunde vorher in
Ordnung war — und kurz genug, dass ein stehengebliebener Sammler die Zahlen
mitnimmt, statt eine alte Stunde als aktuell stehenzulassen.

Der Sammler ist ShouldBeUnique (Codex-Befund, P1). Die provisioning-Warteschlange
ist DIESELBE, auf der Kunden-Bereitstellung läuft; ein stiller Host kostet den
vollen HTTP-Zeitablauf, und ohne diese Sperre stauten sich minütlich neue Läufe
hinter dem alten und verzögerten bezahlte Arbeit. Dasselbe Mittel, das
CollectInstanceTraffic nebenan schon benutzt.

Dazu: der Zustands-Punkt war mit 62 px so groß wie der Speicher-Ring nebenan.
Eine gefüllte Scheibe wiegt optisch weit mehr als ein dünner Ring und erschlug
die Kachel — jetzt 32 px.

Und ein Test, der aus Versehen recht behielt: die Kachel-Prüfung verglich mit
"50", was auch die 500 GB in der Instanzenliste darunter trifft. Sie prüft
jetzt Zahlen, die sonst nirgends auf der Seite vorkommen.

Noch offen, nicht hier angefasst: VmTemplateCheck fragt die Proxmox-API
ebenfalls aus dem app-Container heraus, von der Bereitschaftsseite aus. Selber
Fehler, Bestand, eigener Punkt.

Geprüft: 2299 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit
eingespielten Messwerten: sechs gefüllte Kacheln, Zustands-Scheibe in
Proportion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:23:00 +02:00
nexxo 589489a9fc Release v1.3.92 — sechs Kacheln statt dreier ungleicher Kästen
tests / pest (push) Failing after 10m15s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details
Nach der Vorlage des Besitzers. Zustand, Speicher und die gemeinsame Kurve
standen nebeneinander: zwei davon halb leer, die dritte überfüllt, und die
gemeinsame Kurve brauchte eine Legende, um zu sagen, welche Linie welche ist.

Eine Reihe je Kachel löst alle drei Beschwerden auf einmal. Die Beschriftung
der Kachel benennt die Reihe, also braucht es keine Legende mehr. Die Höhen
sind durch das Raster gleich statt zufällig. Und der leere Platz ist mit
Messwerten gefüllt, die es ohnehin schon gab: Proxmox' Aufzeichnung liefert
Netzdurchsatz in beide Richtungen mit, ungefragt.

Sechs Kacheln: CPU-Auslastung, RAM-Auslastung, Speicher (als Ring), Eingehend,
Ausgehend, Zustand. Gebaut mit x-ui.metric, x-ui.spark und x-ui.ring — die
gab es alle schon, und der Kopfkommentar von x-ui.metric sagt selbst "exactly
as the approved template draws it". Nichts daneben neu gebaut.

Die öffentliche IP steht jetzt auf der Seite. Sie stand vorher NUR klein unter
der Überschrift — die Adresse, unter der der Host wirklich erreichbar ist, war
in der Detailseite nirgends ein Feld. Sie führt jetzt die Ausstattungs-Tafel
an, und die Reserve-Eingabe ist mit dorthin gezogen: die Kacheln zeigen, was
gemessen wurde, die Tafel, was eingestellt ist. Ein Eingabefeld zwischen
Messwerten sähe aus, als ließe sich eine Messung ändern.

Alle vier Verlaufslinien tragen denselben Ton. Die Regel steht im Bauteil
selbst — "muted where the figure is observed, accent where it can be acted on"
—, und hier ist keine Zahl anzufassen. Vier verschiedene Töne nebeneinander
behaupten einen Unterschied, den es nicht gibt.

Zwei Funde aus der Prüfung
--------------------------

- x-ui.spark warf fehlende Messwerte per array_filter heraus und verband die
  Nachbarn. Zwei Fehler auf einmal: die Linie behauptete eine Messung, die es
  nicht gab, und alles danach rutschte nach links — eine Stunde mit zwei Lücken
  zeichnete sich als achtundfünfzig Minuten. Die x-Lage kommt jetzt aus dem
  Platz in der URSPRÜNGLICHEN Reihe, und zusammenhängende Messwerte werden als
  eigene Züge gezeichnet. Eine saubere Reihe ergibt genau einen Zug und
  dasselbe Bild wie vorher, was alle bisherigen Aufrufer liefern.

- Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus v1.3.91 kennt
  netin/netout nicht, und die Host-Seite wäre 55 Sekunden lang an einem
  fehlenden Schlüssel gestorben — genau in der Minute, in der jemand nachsieht,
  ob das Update durch ist. Der Schlüssel heißt jetzt host-load:v2:<id> und
  wandert mit der Form mit.

Und einer, den kein Prüfer gemeldet hat: beim Zerlegen in Züge stand im
Flächenpfad ein `L` unmittelbar vor einem `M`. Gültig gelesen, nicht
gezeichnet — die Füllung verschwand still. Aufgefallen ist es beim Ansehen der
Seite, nicht durch eine Meldung; jetzt prüft ein Test, dass jeder Flächenpfad
mit M anfängt, mit Z endet und keinen Befehl direkt hinter einem anderen trägt.

x-ui.chart behält seinen update-on-Weg, obwohl diese Seite ihn nicht mehr
benutzt: er ist eine geprüfte Fähigkeit des gemeinsamen Bauteils, und der
darunterliegende Fix (Instanz aus dem reaktiven Alpine-Objekt) gilt für jeden
Chart.

Geprüft: 2291 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit
eingespielten Messwerten: sechs Kacheln, Lücke als echte Aussparung in Linie
UND Fläche, Leerzustand zeigt "—" statt einer Null, null Konsolenfehler über
einen vollen Poll-Zyklus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:02:55 +02:00
nexxo 665a0c4e08 Release v1.3.91 — die Host-Seite zeigt Last statt Ausstattung
tests / pest (push) Failing after 9m29s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Die Karte "Rechenleistung" zeigte keine Leistung. 12 Kerne und 63 GB sind die
Ausstattung des Blechs und ändern sich nie — sie standen aber im selben
Kartenraster wie "Zustand" und "Speicher", die beide leben. Wer die Seite
öffnete, um zu sehen, wie es dem Host geht, las dort eine Zahl, die das nie
sagen konnte.

An ihrer Stelle steht jetzt die Last: CPU und RAM als Stundenkurve, beide in
Prozent auf EINER Achse, dazu die aktuellen Werte als beschriftete Zahlen.

Die Geschichte kommt aus Proxmox' eigener Aufzeichnung
(/nodes/{node}/rrddata), nicht aus einem eigenen Sampler. Ein Sampler hieße
neue Tabelle, minütlicher Job, Aufräum-Job und ~1440 Zeilen je Host und Tag —
um weniger genau nachzubauen, was ohnehin auf der Platte liegt. Die RRD ist ab
der ersten Sekunde gefüllt, auch für die Stunde vor dem ersten Hinsehen, und
kann nicht von dem abweichen, was Proxmox' eigene Oberfläche zeigt.

Eine Lücke bleibt eine Lücke: ein Punkt ohne Messwert wird null, nie 0, und
spanGaps steht auf false. Dieselbe Regel wie in instance_metrics. Antwortet der
Host gar nicht, sagt die Tafel das in einem Satz, statt eine ruhige Stunde zu
zeichnen — auf dem Testhost live bestätigt.

Der Fehler, den das ans Licht gebracht hat
------------------------------------------

x-ui.chart steht überall unter wire:ignore, sonst zerstört Livewire das Canvas.
Ein Poll erreicht den Chart also nie. Dafür bekam das Bauteil ein optionales
update-on: es hört auf ein Fenster-Ereignis und tauscht die Daten IM
bestehenden Chart.js-Objekt.

Das lief nicht. Die Zahlen neben der Kurve wanderten, die Kurve nicht, und
chart.update() starb still im Legenden-Plugin:

    TypeError: Cannot set properties of undefined (setting 'fullSize')

Grund: die Chart.js-Instanz lag als Eigenschaft im Alpine-Objekt und wurde
damit reaktiv umhüllt. Chart.js' Plugin-Innenleben überlebt das Proxy nicht.
Gemessen statt geschlossen: dieselbe Instanz wirft über das Proxy und läuft
über Alpine.raw(). Sie liegt jetzt in der Closure.

Aufgefallen ist es nie, weil bis zum ersten Live-Chart kein einziger Chart in
diesem Repo je update() gerufen hat — konstruieren und Erstzeichnen gehen durch
die Hülle noch. tests/Feature/ChartLiveUpdateTest.php hält die Regel fest,
damit der nächste Live-Chart nicht denselben Nachmittag kostet.

Der Rest
--------

- Version lesbar: "Proxmox VE 9.2.6" statt pve-manager/9.2.6/7f8d…, mitten in
  der Bau-Kennung abgeschnitten. Die Kennung steht klein darunter. Eine
  unerwartete Form wird unverändert durchgereicht statt verschluckt.
- Vier Kleinkarten (Mgmt-IP, Node, Version, Instanzen) sind eine
  Ausstattungs-Tafel geworden. Die Instanzen-Anzahl steht in der Überschrift
  der Liste, die sie ohnehin zeigt.
- Der Übernahme-Fortschritt klappt zu, sobald sie durch ist. Fünfzehn
  abgehakte Schritte sind auf einem laufenden Host kein Dauerinhalt —
  aufklappbar über <details>, ohne JavaScript.
- PlanVersion::requiredTemplateVmids() ersetzt die dritte Kopie derselben
  Fensterlogik.
- BuildVmTemplate sagt nicht mehr "this takes 10–20 minutes". Das war eine
  Schätzung vor dem ersten Lauf; gemessen waren es unter zwei. Eine Konsole,
  die falsch vorhersagt, erzieht dazu, sie zu ignorieren.

Geprüft: 2281 Tests grün, Pint sauber, Codex ohne Befund. Die Farbwahl gegen
den Validator gerechnet (ΔE 28,3 protan / 39,2 normal; der Akzent liegt unter
3:1 gegen die Fläche, deshalb tragen beide Reihen sichtbare Beschriftung). Im
Browser: null Konsolenfehler über einen vollen Poll-Zyklus, und die Kurve
wandert auf dem echten Poll ohne Neuladen — mit eingespielten Messwerten
belegt, weil die Testhosts in TEST-NET liegen und nie antworten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:24:21 +02:00
nexxo ac81bc5b98 Release v1.3.82 — Übernahme-Befehlszeile neu ausstellen
tests / pest (push) Failing after 8m50s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Die Zeile gab es genau einmal: auf der Seite direkt nach dem Anlegen. Wer sie
dort nicht kopierte — oder wem sie wegbrach, wie beim WireGuard-Fehler bis
1.3.80 — hatte danach einen Host in der Liste, für den es keinen Weg zu einer
Befehlszeile mehr gab. Ein zweites Anlegen scheitert an der eindeutigen IP,
also blieb nur Entfernen und von vorn.

HostTakeoverCommand behauptet in seinem Kopfkommentar, die Zeile werde "an
zwei Stellen gezeigt (beim Anlegen und beim Neuausstellen eines Codes)". Das
Zweite gab es nie — ein Kommentar, der eine Absicht beschreibt und wie eine
Beschreibung des Gebauten klingt.

Neu: ReissueTakeover als Modal auf der Host-Detailseite. Bestätigen zuerst
(R23), denn ein bisher ausgegebener Code wird dabei wertlos; danach steht die
ganze Zeile mit Kopieren-Knopf darin, nicht nur der Code.

Codex, zwei Runden, beide Male dieselbe Sorte Fehler von mir — die Ansicht
versteckt den Knopf, die Methode prüft nichts:

- P1: `issue()` erzwingt jetzt selbst, welcher Zustand zulässig ist. Ein
  Modal, das offen blieb, während der Host aktiv wurde, rief die Methode
  trotzdem; eine Livewire-Methode ist ohnehin von aussen aufrufbar.
- P2: `issue()` prüft die Tunnel-Einstellungen erneut. Sonst wird ein gültiger
  Code entwertet und dafür eine Zeile ausgegeben, der die WireGuard-Angaben
  fehlen — genau die kaputte Zeile, vor der die Warnung daneben steht.
- P1 der zweiten Runde: `disabled` gehörte nicht in die Liste der zulässigen
  Zustände. Es sieht aus wie "noch nicht fertig" und ist das Gegenteil —
  toggleMaintenance() schaltet einen LAUFENDEN Host so still. Der Knopf hätte
  eine Produktionsmaschine zur Neuinstallation angeboten.

Die Bedingung steht deshalb einmal am Bauteil (ReissueTakeover::eligible) und
wird von Ansicht und Methode gefragt: zwei Fassungen liefen auseinander,
sobald jemand eine ändert.

2231 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:00:53 +02:00
nexxo 903ebdd2b2 Give the operator one line to copy and three steps around it
The console could describe a takeover it had no way to start. This is the
vertical slice that closes that: a one-time code, the archive the rescue system
fetches, and the page that says what to do with both.

The command carries EVERYTHING the script needs before the tunnel exists,
because there is nothing to fetch — that is the whole point of spec §5. Which
means CluPilot generates the WireGuard keypair and admits the peer at the hub
before the machine has ever booted, and hands the private half over in the line.
It is worthless within minutes: task 9 of the script replaces it with one
generated on the machine.

Shown exactly once. The database holds only the code's hash and never the
private key, so leaving the page does not bring it back — it mints a new code,
which invalidates the old one. That is deliberate: a glance at somebody's screen
should be worth nothing an hour later.

Which is also why save() no longer redirects. Sending the operator to the host
detail page sends them away from the only value they need, and an existing test
asserted that redirect — it now asserts the opposite, with the reason written
next to it.

SHA-256 rather than bcrypt for the code, and the reason is not speed. Both
endpoints have to FIND the host by the code; with bcrypt that means trying every
row. The code is 32 characters of CSPRNG output, so it has the entropy that
stretching exists to manufacture.

resolve() and claim() are separate because progress reports arrive BEFORE
registration. If reporting consumed the code, a host could never register after
its first message.

The archive URL is always the public hostname. The console runs under admin.…,
but this line executes on a machine that must not reach the admin area — it is
locked down for exactly that reason — so route() from the console would emit a
hostname that 404s on a server only reachable through the provider's console.

The page warns about missing tunnel settings BEFORE the host is created, not
after. An empty hub key produces a line that looks clean, copies fine, runs, and
ends in a tunnel that never handshakes — discovered on the machine, after
somebody has already paid for it.

The three steps lead with the rescue system, because that is the one nobody
knows by heart, and it says enabling is not the same as booting into it — the
script refuses a running production machine, which is what a half-done switch
looks like from the inside.

1986 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:40:19 +02:00
nexxo 5e22e16291 Send a guest bounced off the console to the console's own sign-in
ApplicationBuilder::withMiddleware() defaults redirectGuestsTo() to
fn () => route('login') before bootstrap/app.php's own callback ever
runs — always the portal's Fortify page, regardless of which side of
the site turned the guest away. In non-exclusive (shared, this VM's
own mode) that sends a guest bounced off /admin to a sign-in form whose
table holds no operators: no 404 any more, but a sign-in that can never
succeed. Exclusive mode happened to self-correct only because
RestrictAdminHost 404s the wrong host before this ever ran.

Fixed for both modes at once rather than depending on which one a given
deployment runs: ask AdminArea, the one place routes/web.php's own
registration and RestrictAdminHost already agree is the source of truth
for "is this the console".
2026-07-28 13:32:05 +02:00
nexxo 87c49de6e5 Move the console's identity out of the customer table 2026-07-28 10:31:43 +02:00
nexxo ba861ad579 fix(security): match ADMIN_HOSTS case-insensitively; share test helpers
DNS names are case-insensitive and Symfony's getHost() always returns lowercase,
so ADMIN_HOSTS=Admin.Example.com rejected every request and locked operators
out. Normalised in the config AND at the comparison, so the value is safe
whichever route it took in (env, cached config, runtime set).

operator()/admin() moved from RbacTest/HostManagementTest to tests/Pest.php:
they were only loaded when those files happened to be in the run, so a targeted
single-file run died on 'undefined function operator()'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:56:26 +02:00
nexxo ef110b06db feat(admin): host reserve edit + maintenance drain; customer suspend/reactivate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:30:21 +02:00
nexxo d3d686e575 feat(admin): scalable host list (search/filter/health) + host detail redesign
- host list: search + datacenter/status filters, dense table with heartbeat
  health dot, instance count, capacity meter
- host detail: health hero (last_seen), storage/compute breakdown, technical
  facts, hosted-instances table
- Host model: usedPct() + healthState() helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:27:53 +02:00
nexxo b44e224bee fix: backfill datacenters on migrate; resolve billing customer by user link
- migration seeds fsn/hel + any host-referenced code so existing installs
  keep selectable datacenters after upgrade
- Billing::customer() resolves via user_id, email only as legacy fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:53:06 +02:00
nexxo bc278c8fa1 feat(admin): datacenters management (create + select)
datacenters table/model + admin CRUD (/admin/datacenters, nav). HostCreate
picks from active datacenters (validated); hosts show the datacenter code.
Seeded fsn/hel. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:25:17 +02:00
nexxo 9e664654a2 fix(engine): record wg peer only after verified handshake; async host purge
- ConfigureWireguard checks setup command results and only records the wg_peer
  resource after the handshake verifies, so a transient setup failure re-runs
  the full setup instead of getting stuck on the idempotent path.
- Host removal now deactivates immediately and queues PurgeHost, which deletes
  runs under the runner lock (waiting out a long step) — no 15s LockTimeout error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:06:13 +02:00
nexxo a6a41719bb fix(engine): long SSH command timeout; wg peer removal on privileged worker
- PhpseclibRemoteShell sets a command timeout (default 2000s) so apt
  full-upgrade/install don't hit phpseclib's ~10s default and fail.
- Host removal dispatches RemoveWireguardPeer to the provisioning queue, which
  runs in the worker that owns wg0 (the web container can't manage WireGuard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:01:02 +02:00
nexxo b65fe69088 fix(engine): drop wg peer on host removal; clear reboot state on deadline fail
- Removing a host now removes its WireGuard hub peer so a freed wg_ip can't
  route to the removed server via a stale peer.
- RebootIntoPveKernel clears reboot_issued/deadline when it times out, so the
  manual retry action actually re-issues a reboot instead of failing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:46:11 +02:00
nexxo 91e308efb0 fix(engine): one host per public IP (unique constraint + validation)
Prevents two onboarding runs from fighting over the same physical server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:43:30 +02:00
nexxo 8a2a2ee695 fix(engine): reset step timer on retry so timed-out steps recover
A retried step (manual or after a timeout) now gets a fresh started_at, so
RunRunner no longer immediately re-detects the timeout and burns the retry
budget without executing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:29:11 +02:00
nexxo 8d12a40a42 feat(admin): host onboarding UI (add / live stepper / retry / remove)
Real hosts list, add-host form (StartHostOnboarding), host detail with live
progress stepper (Reverb + wire:poll fallback), retry failed run, remove via
wire-elements/modal (deregister only). DE+EN. 8 tests.

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