Commit Graph

265 Commits (d7d116e9a59f48d0805d0fa34a3d0383c1e186a7)

Author SHA1 Message Date
nexxo 1886bf2076 Sicht auf die Spuren, und ein Schalter, der die Drossel abstellt
Wenn die Drossel je klemmt, muss der Ausweg ein Klick sein und kein Deployment.

Eigene Seite (admin/mail-pace) statt eines fünften Abschnitts auf admin/mail:
die dortige Seite ist bereits "alles in einer Wurst", und diese hier
beobachtet laufenden Betrieb statt etwas einzurichten — deshalb auch in der
Navigation unter "Betrieb", nicht unter "System".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:44:29 +02:00
nexxo f40b762413 Zwei Empfaengerkreise, und eine Wegwahl, die auch das Konto verschiebt
Fix-Welle nach dem Gesamt-Review, Punkte 4 und 5. Beide sitzen in denselben
Dateien, deshalb ein Commit.

DIE HOST-MELDUNG WAR WORTWOERTLICH DIE KUNDENMAIL (Punkt 4).
notifyHostManagers() verschickte dieselbe SecurityBlockMail. Deren Text ist fuer
den Kunden geschrieben ("bei Ihrer Cloud", "…koennen Sie die Sperre auf der
Sicherheitsuebersicht aufheben"), der Knopf zeigte auf portal.security — wo ein
Betreiber kein Konto hat und Host-Sperren nie erscheinen —, welcher Host
betroffen ist stand nirgends, und die Anrede blieb leer, weil $block->instance
null ist.

Die Mail kann jetzt beide Faelle. Unterscheidungsmerkmal in der Ansicht ist
$hostName: gesetzt heisst Host-Sperre, null heisst Instanz-Sperre. Ein zweites
Flag daneben waere eine zweite Wahrheit.

- Betreff nennt den Host.
- Eigener Textblock (mail_heading_host, mail_preheader_host, mail_intro_host mit
  "fehlgeschlagene SSH-Anmeldungen am Host :host"), eigene Tabellenzeile
  field_host, eigener Hinweis mail_note_host.
- Der Knopf zeigt auf admin.hosts.show, also auf die Host-Detailseite der
  Konsole, wo die Host-Sperren mit ihrem Aufheben-Knopf stehen.
- Keine Anrede im Host-Fall — sie war vorher leer, und der Empfaengerkreis ist
  ohnehin mehrere Personen.
- Der Fehlalarm-Satz ist ein anderer und sagt, was ein Betreiber wissen will:
  bestehende Verbindungen bleiben unberuehrt, die Regel steht unter
  "established,related accept".

R19: die zwei Kommentare, die fuer Host-Sperren Unwahres behaupteten, sind
richtiggestellt — der Kopfkommentar von SecurityBlockMail und der
MailCatalogue-Eintrag. Beide beschreiben jetzt zwei Empfaengerkreise. Ein
Kommentar mit falscher Begruendung haelt den Naechsten vom Nachsehen ab.

DIE WEGWAHL VERSCHOB DEN ABSENDER, NICHT DAS KONTO (Punkt 5).
SecurityBlockMail nagelte mailer('cp_system') fest und holte das Postfach ueber
die Wegwahl. MailboxTransport loeste weiter ueber den ZWECK auf und
authentifizierte mit dessen Zugangsdaten. Legte der Betreiber security-block auf
info@ — der ausdrueckliche Anlass fuer Abschnitt 3a der Spec —, ging die Mail
mit From: info@ durch die SMTP-Sitzung von no-reply@. Viele Server weisen das ab
("553 5.7.1 Sender address rejected: not owned by user"), und dann verschwindet
ausgerechnet die Mail, die er gerade umgelegt hat. Betraf ALLE Mailarten.

Die Wegwahl bestimmt jetzt auch den Mailer. Die zweitbeste Loesung — die Auswahl
in der Konsole auf Postfaecher zu begrenzen, deren Konto benutzt wird — war
nicht noetig.

- config/mail.php erzeugt AUS MailCatalogue::all() je Mailart einen Mailer
  cp_mail_<mailart> mit transport, purpose und mail_key. Erzeugt statt
  aufgelistet, weil der Katalog ausdruecklich die EINE Liste ist; eine zweite
  hier waere die, die beim siebzehnten Mail vergessen wird. Genauso statisch wie
  die fuenf Zweck-Mailer: ein reines Array, keine Datenbank, auch unter
  config:cache unveraendert.
- MailboxTransport nimmt den mail_key entgegen und loest damit ueber DIESELBE
  MailRoute::purposeOrMailbox() auf, die auch der Umschlag fragt. Ohne
  Schluessel entscheidet wie bisher allein der Zweck — jeder Mailer, der keinen
  hat, verhaelt sich unveraendert.
- Jede Mailart mit Schluessel benennt ihren Mailer ueber
  MailCatalogue::mailer(). Dessen Rueckfall auf cp_<zweck> fuer einen
  unbekannten Schluessel ist nicht Zierde: DunningNoticeMail baut seinen
  Schluessel aus der Mahnstufe, und ohne den Rueckfall waere eine Stufe
  ausserhalb 0-3 ein "Mailer [...] is not defined" und damit ein Totalausfall
  statt eines falschen Absenders.
- Betrifft alle Mailarten: 13 Mailables, die CloudReady-Notification und die
  cloud-ready-Vorschau. ContactRequestMail bleibt auf cp_support — es hat keinen
  Schluessel im Katalog, weil es keine eigene Mailart ist.

MailSenderOwnershipTest ist mitgezogen und dabei STRENGER geworden. Die alte
Fassung verglich "From-Zweck == Mailer-Zweck"; jetzt prueft sie, dass der
Katalog dem Zweck zustimmt, den der Umschlag verlangt. Katalog-Drift war bis
hierher nur als Warnung im Docblock von MailCatalogue festgehalten und durch
nichts erzwungen. Ein Literal, das selbst kein Schluessel ist, wird als PRAEFIX
behandelt (der Mahnlauf) — dann muessen alle Schluessel dahinter zustimmen, und
es muss mindestens einen geben.

MailPreviewTest fragte den Zweck bisher, indem es 'cp_' vom Mailer-Namen
abschnitt. Das war eine Zusicherung ueber das Namensschema, nicht ueber die
Zusage; sie liest den Zweck jetzt aus der Konfiguration des Mailers selbst.

tests/Feature/Mail/SenderAddressTest.php gehoert inhaltlich zur parallel
laufenden Sitzung. Vier Zusicherungen darin pruefen genau den Mailer-Namen, den
dieser Commit aendert, also sind sie mitgezogen — die Datei war dabei sauber,
es steckt keine fremde unfertige Arbeit in diesem Commit.

Committet mit ausdruecklicher Dateiangabe am Zeilenende, weil eine parallele
Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:20 +02:00
nexxo 86477ad265 Der Aufheben-Knopf im Portal bekommt seinen Abstand zurueck
Icon und Beschriftung klebten aneinander: die Klassen fuer Anzeigeart,
senkrechte Ausrichtung und Abstand fehlten als EINZIGE Stelle im Repo — jeder
andere Knopf mit Icon und Text traegt sie. Es ist der zentrale Knopf der neuen
Portalseite, der einzige, den ein Kunde dort ueberhaupt drueckt.

Kein Test hat es gefangen: IconLayoutTest prueft Zeilenumbruch und Groesse,
nicht die Luecke dazwischen. Gefunden hat es das Review beim Vergleich mit den
Knoepfen im uebrigen Repo.

Die beiden Konsolen-Ansichten haben denselben Knopf OHNE Icon, brauchen die
Klassen also nicht — nachgesehen, nicht angenommen.

Committet mit ausdruecklicher Dateiangabe am Ende der Zeile, weil eine parallele
Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:59:07 +02:00
nexxo 51a97019a8 Sperren sehen und aufheben: Portalseite fuer den Inhaber, Abschnitte in der Konsole
Aufgabe 6 des Fruehwarnsystems. Der Inhaber sieht im Portal die Sperren SEINER
Instanzen und hebt sie dort auf; der Betreiber sieht in der Konsole alle, an der
Kunden- und an der Host-Detailseite, und auf der Uebersicht steht ein Hinweis,
solange irgendwo eine Sperre aktiv ist.

Aufgehoben wird ueber ein Bestaetigungs-Modal (R23), und das Modal mutiert
nichts: es wirft ein Ereignis, das die Seite auffaengt und an ihre eigene
Methode weiterreicht. Die Berechtigungspruefung bleibt damit an der einen
Stelle, an der sie schon stand — noetig, weil ein Modal ohne die Middleware der
Seite erreichbar ist (R20). Dazu die Berechtigung `instances.manage`, nach dem
Muster der bestehenden `instances.restart`-Migration; Abrechnung und Read-only
bleiben unberuehrt.

ACHTUNG, was hier sonst noch drinsteckt und NICHT zu dieser Aufgabe gehoert:
rund 150 Zeilen zum Versandtakt — das Merkmal `RidesALane`, vierzehn Mailables
und `MailLaneRoutingTest`. Die stammen aus einer PARALLEL laufenden Sitzung an
einem anderen Feature.

Wie das hineingeriet: der Implementierer dieser Aufgabe brach vor dem Commit ab
und liess seine fertige Arbeit ungespeichert im Baum. Ich habe sie dateigenau
mit `git add <dateien>` vorgemerkt, um nichts Fremdes mitzunehmen — und dabei
uebersehen, dass `git add` nur HINZUFUEGT: die andere Sitzung hatte ihre Arbeit
bereits vorgemerkt, und `git commit` schreibt den ganzen Index, nicht nur das
zuletzt Hinzugefuegte. Richtig waere `git commit -- <dateien>` gewesen.

Nichts ist verloren, und die volle Suite ist auf diesem Stand gruen (2571).
Aber diese Botschaft soll nicht behaupten, sie beschreibe alles, was hier steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:53:24 +02:00
nexxo 2d979052d0 Benachrichtigung ueber eine gesperrte Adresse, hoechstens eine je Stunde
SecurityBlockMail geht bei einer Instanz-Sperre an die Kontoadresse des
Inhabers, aus dem SYSTEM-Postfach wie NewDeviceSignInMail. Die Drossel sitzt
in BlockAddress::notifyInstanceOwner() ueber Settings (kein neues Feld fuer
etwas, das nach einer Stunde niemanden mehr interessiert) und wird VOR dem
Versandversuch gesetzt. Ein Throwable beim Verschicken wird gemeldet und
verschluckt: die Sperre steht schon, bevor ueberhaupt versucht wird zu
verschicken, und ein kaputtes Postfach darf sie nicht rueckgaengig machen.

Host-Sperren verschicken bewusst noch keine Mail: kein Muster im Repo, wie
eine Betreiber-Meldung ihren Empfaenger findet (siehe Bericht).

Route 'portal.security' minimal angelegt (Aufgabe 6 baut die echte Seite) -
auf einem eigenen Pfad, weil sie sich mit der oeffentlichen /security-Seite
sonst lautlos gegenseitig ueberschreiben, sobald Portal und Website ohne
eigene Domain laufen (RouteCollection indiziert ueber Methode+Domain+URI,
nicht ueber den Namen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:39:35 +02:00
nexxo 5dcfee9957 Wegwahl je Mailart: welche Mail aus welchem Postfach geht
MailCatalogue haelt die eine Liste aller sechzehn Mailarten (Schluessel,
Beschriftung, Vorgabe-Zweck), aus MailPreviews herausgezogen, damit es
nur noch eine Stelle gibt, die beim naechsten Mailtyp vergessen werden
kann. MailRoute sitzt darueber: ein Eintrag ist eine Ausnahme fuer GENAU
diese eine Mailart, keine zweite Zuordnungsebene — ohne Eintrag oder bei
abgeschaltetem Zielpostfach faellt sie unveraendert auf den Zweck
zurueck, den MailboxResolver schon kennt.

SendsFromMailbox bekommt dafuer einen optionalen $mailKey; alle
bestehenden Aufrufer (inklusive ContactRequestMail, das mailboxAddresses
selbst zusammensetzt) bleiben bei null und damit beim alten Verhalten.
Jede Mailklasse und die CloudReady-Benachrichtigung nennen jetzt ihren
Katalog-Schluessel. Die Konsole bekommt eine vierte Karte unter der
Zweck-Zuordnung: eine Zeile je Mailart, ein <select> mit den aktiven
Postfaechern und "wie der Zweck (...)" als Vorgabe.

Der wichtigste Test schickt eine Mail ohne jeden Eintrag und prueft,
dass sie exakt beim bisherigen Postfach landet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:43:31 +02:00
nexxo 5ae2dc41fc Ein Waechter, der aufraeumt — und die vier offenen Punkte aus dem Betrieb
DER WAECHTER. Ein abgebrochenes Update liess den Stapel halb unten liegen, den
Tunnel weg und die Seite eine Stunde lang mit 500 antworten — bis jemand von
Hand nachsah. Das wartet nie wieder auf einen Menschen.

deploy/watchdog.sh laeuft jede Minute als systemd-Dienst AUF DEM WIRT, nicht in
einem Container: ein Waechter im Container braeuchte den Docker-Socket
hineingereicht (Root auf dem Wirt fuer jeden, der je hineinkommt) und waere
genau dann tot, wenn man ihn braucht. Er nimmt dieselbe Sperre wie der
Update-Agent und fasst waehrend eines Updates nichts an.

Er kennt vier Fehlerbilder, alle vier heute wirklich passiert, und fuer jedes
genau einen Griff: fehlende Dienste starten; Container, die einander nicht mehr
finden, NEU ERZEUGEN (ein Neustart hilft da nicht); wg0 hochziehen; einen seit
ueber einer halben Stunde haengenden Wartungsmodus beenden. Was er nicht kennt,
protokolliert er und laesst es in Ruhe. `maintenance-hold` ist die Handbremse.
Beides nachgemessen: Dienst gestoppt -> wieder da; wg0 abgeraeumt -> wieder oben.

DREI LUECKEN, DIE DERSELBE VORFALL AUFGEDECKT HAT:

1. `phase()` machte `mkdir -p` ohne Fangnetz. Gehoerte storage/ nach einem
   frueheren Fehltritt root, starb das Update mit `set -e` an seiner ERSTEN
   Zeile, ohne eine einzige Ausgabe. Von aussen sah das aus wie
   "haengengeblieben"; in Wahrheit war es nach einer Millisekunde vorbei.
2. Ein zweiter Aufruf meldete "Already up to date" und tat NICHTS — der Checkout
   stand ja schon auf dem Ziel. Der Commit ist die falsche Frage; jetzt wird der
   Zustand gefragt: laeuft jeder Dienst, ist der Wartungsmodus aus.
3. Nach einer Netz-Umstellung reicht `up -d` nicht: nur neu gestartete Container
   haengen weiter am alten Netz, alle laufen, und trotzdem loest kein Name mehr
   auf. Jetzt `--force-recreate`.

DIE VIER PUNKTE AUS DEM BETRIEB:

- Provisioning zeigte 15/16 in der Liste und "16 von 16" in der Karte daneben,
  bei Status "Fertig" und 100 %. Die Liste rechnete `current_step + 1` — "dieser
  Schritt laeuft gerade" —, und bei einem fertigen Lauf laeuft keiner mehr.
- Mahngebuehren standen in CENT im Feld. Wer eine Gebuehr eintraegt, denkt in
  Euro und tippt "5"; daraus wurden fuenf Cent, ohne Widerspruch. Jetzt Euro im
  Feld, Cent in der Datenbank, gerundet statt abgeschnitten ((int)(19.99*100)
  ist 1998). Und Fristen und Geld stehen in zwei eigenen Bloecken mit eigener
  Ueberschrift, die Einheit im Feld statt in der Beschriftung.
- Die Bueroadresse stand in der Konsole neben dem Management-Netz mit dem
  Vermerk "nicht entfernbar" — sie kommt aber aus TRUSTED_RANGES in der .env und
  ist sehr wohl aenderbar. Beim naechsten Umzug des Anschlusses waere das ein
  Aussperren gewesen. Strukturell sind nur zwei Eintraege; alles andere steht
  jetzt als das da, was es ist, mit dem Weg heraus.
- Ein Host-Zugang hiess weiter "pve-fns-1", waehrend der Host laengst "fsn-01"
  hiess. Die Zeile zeigt jetzt den Namen des Hosts, nicht den einmal
  gespeicherten — damit traegt sie jede kuenftige Umbenennung von selbst.

Und die Frage "wofuer brauche ich Uptime Kuma": es ist benutzt — RegisterMonitoring
legt fuer jede Kunden-Instanz eine Ueberwachung an, SyncMonitoringStatus holt den
Stand, und ein Ausfall steht auf der Uebersicht. Ohne Kunden-Instanzen gibt es
nichts zu sehen, was wie "unbenutzt" aussieht. Das steht jetzt in den
Einstellungen, statt dort nur "API-Token und wo die Bruecke erreichbar ist".

2522 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:43:33 +02:00
nexxo e3c781f067 Der Tunnel bekommt einen eigenen Container
Bisher wohnte wg0 im Container des Provisionierungs-Arbeiters, also im Abbild
`clupilot-app`. Das wird bei fast jeder Freigabe neu gebaut — und ein neu
gebauter Container bekommt eine neue Adresse im Compose-Netz, womit die
Weiterleitung fuer UDP 51820 neu geschrieben wird und JEDE bestehende
WireGuard-Sitzung abreisst. Der Tunnel hing damit am Veroeffentlichungstakt der
Anwendung, und zusaetzlich daran, dass ein PHP-Prozess nicht abstuerzt.

Jetzt gehoert der Netz-Namensraum einem eigenen Container `vpn-hub` mit eigenem,
winzigem Abbild (Alpine plus wireguard-tools), das sich fast nie aendert.
Provisionierungs-Arbeiter, Terminal-Bruecke, interner DNS und internes Gateway
steigen dort ein, statt einer von ihnen den Namensraum zu besitzen.

NACHGEMESSEN, nicht angenommen: App-Abbild neu gebaut, Arbeiter, Bruecke und
Gateway per --force-recreate neu erzeugt — der Hub blieb Container 92e928cf53b0,
wg0 und beide Zugaenge unangetastet, und nginx erreichte die Bruecke weiter
(HTTP 426). Genau der Vorgang, der bisher jedes Mal alles abgerissen hat.

Nachgezogen:
- nginx spricht die Bruecke unter `vpn-hub:8082` an — dem Namen des
  Namensraum-Eigentuemers; ein Mitbewohner hat keinen eigenen DNS-Eintrag.
- update.sh baut vpn-hub mit und haengt Nachbar-Neustarts und den
  conntrack-Griff an die Frage, ob der Hub WIRKLICH neu gebaut wurde.
- update-agent.sh startet den Arbeiter nicht mehr neu, sondern signalisiert ihm.
  Diese Stelle laeuft unbeaufsichtigt hinter dem Knopf „Dienste neu starten" —
  wer den drueckt, rechnet nicht damit, sich selbst auszusperren.
- Vier Meldungen in der Konsole rieten dem Betreiber, genau den Befehl von Hand
  auszufuehren, der ihm den Tunnel abreisst. Auch die sind korrigiert.
- rescue-tunnel.sh und das Runbook zeigen auf den neuen Besitzer.

Eine Kleinigkeit unterwegs, die ich falsch angekuendigt hatte: `[[ … ]] && x=true`
bricht unter `set -e` NICHT ab — bash nimmt die linke Seite einer &&-Liste
ausdruecklich aus. Nachgeprueft; die if-Form bleibt trotzdem, aus Lesbarkeit, und
der Kommentar sagt jetzt den wahren Grund.

2509 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:49:29 +02:00
nexxo 894dcd309b Wortmarke breiter, und die Hostzeile steht wieder auf einer Linie
tests / pest (push) Failing after 12m48s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Zwei Dinge, die dem Betreiber am fertigen Bild aufgefallen sind.

DIE WORTMARKE. Sechs Zellen pro Buchstabe liessen sie hoch und schmal wirken;
acht treffen die Proportion. Damit ist die Zeichnung 65 statt 51 Zellen breit,
und bei 13px rund 500px — auf dem Schirm eine Wortmarke, kein Etikett. Am
Telefon 7px und damit rund 270px, was neben den Raendern hineinpasst.

DIE HOSTZEILE. „Online" und „Verbunden" standen 3px versetzt, obwohl beide
Zellen gleich hoch sind (18,6px, gemessen). Der Grund: ein `inline-flex` holt
sich seine Grundlinie vom ERSTEN Kind. Links ist das ein 8px-Punkt, rechts ein
16px-Icon; beide sitzen in ihrer Zeile mittig, ihre Unterkanten liegen deshalb
4px auseinander, und genau darauf setzt der Browser die Grundlinie der ganzen
Zelle. Mit `align-middle` richtet sich der Kasten nach seiner Mitte statt nach
dem ersten Kind. Nachgemessen: vorher 361,3 gegen 358,5, jetzt beide 361,6 —
Versatz 0px.

Beides im Browser gemessen, nicht geschaetzt. 2508 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:21:24 +02:00
nexxo 8538fed0b4 Terminal: die Wortmarke steht gerade
Der Betreiber sah einen losen Klotz mitten im Wort. Ursache war der Bogen des
`P` in der uebernommenen Figlet-Zeichnung: eine Zelle in der zweiten Zeile, unter
der in der ersten nichts stand. Figlet-Schriften sind fuer echte
Terminalschriften gezeichnet und tragen in IBM Plex Mono nicht.

Jetzt selbst gezeichnet: jeder Buchstabe sechs Zellen breit (das `I` zwei), eine
Zelle Abstand, und keine Zelle ohne Nachbarn darueber. Nicht von Hand abgezaehlt,
sondern aus einer Buchstabentabelle gesetzt — von Hand hatte ich mich prompt
verzaehlt und die Buchstaben liefen ineinander, was im Browser sofort zu sehen
war.

Groesser als vorher (15px statt 11px, am Telefon 10px): die neue Zeichnung ist
51 Zellen breit statt 62, bei gleicher Schriftgroesse wirkte sie deshalb
kleiner.

Zwischendurch stand hier „CluPilot Cloud" mit grauem zweiten Wort wie im Logo.
Auf Wunsch des Betreibers wieder raus — nur die Wortmarke.

2508 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:16:28 +02:00
nexxo 92ebd5d545 Terminal: das Fenster sagt jetzt selbst, ob die Bruecke laeuft
Der Betreiber oeffnete ein Terminal und las "Keine Verbindung — laeuft der
Terminal-Dienst, und steht der Tunnel?". Das war keine Diagnose, das war eine
Rueckfrage an den, der gerade unterwegs ist und nicht nachsehen kann.

Der Grund ist eine Eigenart des Protokolls: scheitert ein WebSocket schon am
Handschlag, bekommt die Seite laut Norm KEINEN HTTP-Status — `event.code` ist
1006, sonst nichts. Das ist Absicht (sonst waere ein Socket ein Portscanner) und
macht ausgerechnet die Unterscheidung unmoeglich, auf die es hier ankommt: laeuft
die Bruecke nicht, oder ist die Leitung weg? Beides sah gleich aus.

Eine gewoehnliche Anfrage an dieselbe Stelle darf den Status sehr wohl sehen.
Scheitert der Socket, ohne dass je ein Byte kam, fragt das Fenster deshalb einmal
nach und liest die Antwort:

  502/503/504  nginx erreicht die Bruecke nicht -> "Der Terminal-Dienst laeuft
               nicht", mit dem Befehl, der ihn zurueckholt, und dem Hinweis auf
               den geteilten Netz-Namensraum
  404          oeffentlicher Hostname -> "Auf diesem Namen gibt es kein Terminal"
  sonst        die Bruecke lebt, die Sitzung ist an etwas anderem gescheitert;
               "Keine Verbindung" bleibt stehen
  gar nichts   die Anfrage kam nicht einmal los -> die Leitung ist wirklich weg

Alle drei Faelle nachgemessen, nicht angenommen: Bruecke laeuft -> 426,
oeffentlicher Name -> 404, `docker compose stop terminal` -> 502. Und danach mit
gestoppter Bruecke im Browser angesehen, im selben Zustand, in dem der Betreiber
gerade steht.

Dazu ein Test, der jeden Schluessel abdeckt, den terminal.js an showStage()
uebergeben kann — ein fehlender schriebe "undefined" ins Fenster, und zwar
ausgerechnet in dem Moment, in dem etwas kaputt ist.

2507 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:34:32 +02:00
nexxo 4fc1ccd3db Terminal: die Bruecke ueberlebt jetzt ein Deployment
Aus dem Gesamt-Review, und der erste Befund ist der, der still zugeschlagen
haette.

`terminal` lebt im Netz-Namensraum von `queue-provisioning`, und ein Prozess
bleibt in dem Namensraum, in dem er gestartet ist. `update.sh` startet den Hub
neu — danach lauscht die Bruecke in einem, den es nicht mehr gibt. Nichts meldet
dabei einen Fehler: `docker compose ps` sagt weiter "healthy", weil die
Lebendpruefung ueber Loopback INNERHALB des verwaisten Namensraums laeuft. Nach
aussen antwortet nginx mit 502, und der Betreiber liest "Keine Verbindung —
laeuft der Terminal-Dienst?", waehrend der Dienst behauptet, es gehe ihm gut.
Genau dieselbe Falle, die zwei Bloecke tiefer schon fuer vpn-dns/vpn-gateway
behandelt ist; die Bruecke fehlte in der Behandlung.

Nachgemessen statt geglaubt: Hub neu gestartet -> Docker sagt "healthy", curl aus
dem Namensraum bekommt gar keine Antwort. Nach `restart terminal`: 200.

Und ein zweiter Ausrollfehler daneben: gebaut wurde nur `app`. `docker compose
up -d` baut nur Images, die es noch GAR NICHT gibt — beim ersten Ausrollen faellt
das nicht auf, danach nie wieder. Eine Aenderung an docker/terminal/ saehe
ausgeliefert aus, und es liefe das alte Image.

Ausserdem:
- Die Meldung zu 4502 zaehlte zwei Ursachen auf, der Code deckt fuenf. Die
  Bruecke schickt 4502 fuer JEDE gescheiterte Anmeldung, auch fuer einen
  abgewiesenen Schluessel — und das ist der wahrscheinlichste Fall, wenn ein Host
  neu aufgesetzt wurde. "antwortet nicht" war dort schlicht falsch: die Maschine
  hat geantwortet und abgelehnt. Titel und Text legen sich nicht mehr fest.
- R19: der Kommentar an der Kopfzeile der Spalte nannte "Berechtigung,
  Betriebsbereitschaft" als Grund, warum der Knopf nicht ueberall steht.
  Letzteres entscheidet seit dem Entsperren nichts mehr, und zwanzig Zeilen
  tiefer begruendete der Kommentar am Knopf ausfuehrlich das Gegenteil.
- Der Kommentar am Retry-Knopf erklaerte die Reihenfolge von .hidden gegen
  .inline-flex fuer zu unsicher, waehrend die Buehne dreissig Zeilen hoeher genau
  darauf baut. Tailwind gibt .hidden als letzte Display-Klasse aus; der wahre
  Grund fuer den Wrapper ist, dass die Klassen des Knopfes aus einem geteilten
  Bauteil kommen.
- REDIS_URL stand fest auf Datenbank 1, waehrend PHP REDIS_CACHE_DB liest. Wer
  die anfasst, legt auf der einen Seite ab, wo die andere nicht sucht.

2507 Tests gruen, compose config und bash -n sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:41:33 +02:00
nexxo f64a564c40 Terminal: keine Fehlerseite mehr, ein Knopf auf jeder Zeile, eine Buehne statt roter Zeilen
Drei Dinge, die beim ersten Hinsehen im Betrieb auffielen.

1. Der Knopf war weg. Er verschwand, wenn dem Host die Tunneladresse oder der
   Fingerabdruck fehlte — mit der Begruendung, ein Knopf, der verlaesslich in
   eine Ausnahme laeuft, sei schlechter als gar keiner. Das stimmte, solange die
   Seite dahinter mit Laravels Fehlerseite aufging. Jetzt steht er auf jeder
   Zeile: ein fehlender Knopf sah aus wie "hier gibt es kein Terminal" statt
   "hier noch nicht, und zwar deshalb".

2. Die Seite ging mit einem Stacktrace auf. `TerminalTicket::issue()` warf,
   niemand fing es, und wer den Knopf drueckte, bekam Klassenname, Dateipfad,
   Zeilennummer und Quelltextauszug in einem Fenster des eigenen Produkts.
   `blocker()` beantwortet die Frage jetzt VOR dem Ausstellen und gibt ein
   Merkwort zurueck, keinen Satz — die Formulierung gehoert in die
   Sprachdateien. `mount()` wirft nicht mehr, mit Fangzaun fuer das, womit
   niemand gerechnet hat.

3. Der Abbruch war die einzige ungestaltete Stelle im Produkt: eine rote
   ANSI-Zeile mitten in der eigenen Ausgabe. Der Vorspann und der Schirm waren
   Geschwister, von denen abwechselnd eines `hidden` trug — das trug genau
   einmal, beim Aufbau, und fuer alles danach fehlte die Rueckfahrkarte. Die
   Buehne liegt jetzt UEBER dem Terminal und kann dreimal auftreten: beim
   Verbinden, beim Ende, beim Abbruch. Die Sitzung darunter bleibt stehen.
   Welcher Text, entscheidet der Schliesscode der Bruecke (4401 Ticket, 4502
   kein SSH); dazu ein Knopf, der neu laedt, weil ein Ticket dreissig Sekunden
   gilt und genau einmal.

Beim Hinsehen gefunden, nicht beim Testen:
- Die Schriftgrafik war unlesbar. Die Figlet-Zeichnung setzt darauf, dass der
  Unterstrich einer Zeile den Strich der naechsten beruehrt; in IBM Plex Mono
  sitzt er tiefer. Eng verschmierte das Wort, weit zerfiel es. Vollbloecke
  fuellen ihre Zelle und stapeln in jeder Schrift.
- Dunkelrot auf Fast-Schwarz hatte kaum Kontrast. Die Wortmarke bleibt jetzt
  immer in der Akzentfarbe — sie ist keine Statuslampe, was los ist, sagt die
  Zeile darunter.
- Auf dem Schirm stand ":host antwortet nicht". Der Name war an die Erklaerung
  uebergeben, an die Ueberschrift nicht. Ein Test mit
  `toContain(__('...title'))` haette das nie gefunden — er verglich ":host" mit
  ":host". Der neue prueft das Ergebnis.

Nachgewiesen: Knopf oeffnet ein NEUES Tab (die Liste bleibt stehen), Ticket
ausgestellt, Socket verbunden, Bruecke kommt nicht auf den Host, schliesst 4502,
Buehne kommt mit "pve-fsn-1 antwortet nicht" und Knopf zurueck, Knopf laedt
wirklich neu und holt ein frisches 64-Zeichen-Ticket. Der Fingerabdruck dafuer
war geliehen und ist wieder entfernt.

2507 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:27:35 +02:00
nexxo c6403bf829 Terminal-Bruecke: ein Container, eine Aufgabe, ein Ticket
Der Beiwagen nach dem Vorbild von kuma-bridge: Python, ein Zweck, kein
Laravel. Er steht im Netz-Namensraum von queue-provisioning, weil dort wg0
lebt und nur von dort ein Host ueberhaupt erreichbar ist.

Drei Punkte, die das Review von Aufgabe 2 offen gelassen hat, sind hier
entschieden:

- Das Ticket reist als Sec-WebSocket-Protocol, nicht in der Adresszeile.
  Die Adresse eines Upgrade-Antrags schreibt jeder Reverse Proxy mit, und
  auf der Strecke stehen zwei, von denen nur einer aus diesem Repo
  konfiguriert wird.
- /terminal/ws bleibt an der Wurzel, unabhaengig von AdminArea::prefix():
  nginx kann die Konfiguration der Anwendung nicht lesen, der Ort muss ein
  fester Text sein. In beiden Dateien steht die Begruendung.
- Die Bruecke sendet ausschliesslich Binaerrahmen.

Abweichungen vom Plan, jeweils gemessen statt vermutet:

- Der Schluessel in Redis traegt REDIS_PREFIX (clupilot-database-), nicht
  CACHE_PREFIX: TerminalTicket schreibt ueber Redis::connection('cache'),
  und phpredis stellt die Praefix-Option dieser Verbindung voran.
- Port 8082 statt 8081: im selben Namensraum lauscht der VPN-Gateway schon
  auf VPN_HEALTH_PORT, und der zweite Zuhoerer auf einem Port stirbt.
- SSH ueber paramiko.Transport statt SSHClient. SSHClient prueft gegen
  known_hosts, die dieser Container nicht hat und aus einem Fingerabdruck
  auch nicht bilden kann; RejectPolicy verbaende nie, AutoAddPolicy
  meldete sich mit einem Root-Schluessel an, bevor irgendetwas geprueft
  ist. Der Transport erlaubt die richtige Reihenfolge: Handschlag,
  Fingerabdruck, dann erst Anmeldung.
- Der Fingerabdruck wird gebildet wie in PhpseclibRemoteShell, nicht wie
  bei OpenSSH: gehasht wird "<algorithmus> <base64-blob>", nicht der Blob.
- proxy_pass ueber eine Variable mit resolver, damit nginx nicht beim
  Start scheitert, wenn die Bruecke gerade nicht laeuft.
- /terminal/ws antwortet auf einem oeffentlichen Hostnamen mit 404,
  dieselbe Regel wie /admin eine Zeile darueber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:09:15 +02:00
nexxo 4e52c35f24 Terminal Fix-Runde 1: Ticket-Zusicherung im Test, ehrliche Kommentare, ein Ereignis eine Meldung
- Der Vorspann-Test prüft jetzt data-terminal-splash und die Ticketform
  (64 Hexzeichen) selbst, statt sich auf ein aria-label zu verlassen, das
  nur wegen des Tests existiert.
- Neuer Test: Berechtigung vor Nachschlagen — eine erfundene UUID meldet
  403, nie 404.
- Der Knopf-Sichtbarkeitstest sichert jetzt auf die konkrete Terminal-Route
  zu statt auf das nackte Wort "Terminal" irgendwo auf der Seite.
- Drei Kommentare (terminal.js, bare.blade.php, vite.config.js) behaupteten,
  die Seite liefe ohne Livewire/Chart.js — sie ist aber eine Vollseiten-
  Livewire-Komponente und zieht app.js über <x-shell.head> ohnehin mit.
  Kommentare korrigiert: eigener Einstiegspunkt, damit der Terminalcode
  nicht in app.js landet, nicht weil die Seite ohne Livewire liefe.
- terminal.js: ein fehlgeschlagener Socket feuert error UND danach close;
  onclose schweigt jetzt, wenn nie ein Byte ankam, statt "Verbindung
  beendet" hinter "Verbindung nicht möglich" zu schreiben.
- terminal.js: Textrahmen landen jetzt als String im Terminal statt als
  leeres Uint8Array.
- data-host wird jetzt gelesen und steht in den Verbindungsmeldungen.
- wire:ignore auf dem Terminalschirm, bevor die Komponente ihre erste
  Aktion bekommt und xterms DOM beim nächsten Render löscht.
- hosts.blade.php/host-detail.blade.php: der Terminal-Knopf trägt sein
  href jetzt selbst (x-ui.button :href), statt in einem <a> zu stecken —
  interaktiver Inhalt in einem Link war ungültiges HTML.

Suite: 2502 bestanden (vorher 2501 + ein neuer Test).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:32:58 +02:00
nexxo 941950584c Terminal: eigenes Fenster mit Vorspann, Knopf in Liste und Detailseite
Aufgabe 2: alles, was der Betreiber sieht, noch ohne Container dahinter.
Der Knopf bleibt fuer einen Host ohne Tunneladresse oder Fingerabdruck
absichtlich unsichtbar, statt in eine unbehandelte RuntimeException aus
Aufgabe 1 zu fuehren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:18:22 +02:00
nexxo 325e69f8f0 Host-Liste holt sich ihren Stand selbst; die Ausstattung braucht keine Zeile nur fuer die Adresse 2026-08-02 17:00:30 +02:00
nexxo d8aee6ec2f Zwei Attrappen: ein Banner ohne Hintergrund und Rechte, die ihren Schluessel statt ihrer Beschreibung zeigten 2026-08-02 16:47:15 +02:00
nexxo 06d87f2111 Offene Punkte: was fehlt und was folgt nebeneinander, statt auf halber Breite zu enden 2026-08-02 16:40:26 +02:00
nexxo d7bb0f2e63 Offene Punkte: nach Dringlichkeit gruppiert, in der Formensprache der Bereitschaftsseite 2026-08-02 16:35:14 +02:00
nexxo 07c51474d3 Update drehte sich im Kreis: es ersetzte sich selbst mitten im Lauf; dazu eine Seite fuer offene Punkte 2026-08-02 02:02:25 +02:00
nexxo 2c30d1fb86 Host anlegen: die Seite beschrieb das Rettungssystem und startete die Pipeline 2026-08-02 01:38:48 +02:00
nexxo c0f83ab957 Seitenleiste neu geordnet: Betrieb trug zwoelf Eintraege aus drei Themen
tests / pest (push) Failing after 9m21s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-08-02 00:29:14 +02:00
nexxo 5413628c26 Nicht mehr damit werben, in welcher Sprache wir antworten
Der Betreiber will die Zusage "auf Deutsch" nirgends mehr stehen haben. Sie
stand an neun Stellen: auf der Anfrageseite dreimal, auf der Startseite in der
Seitenbeschreibung, in einer Karte und in einer Antwort der Fragenliste, in der
Fusszeile, in der Merkmalsliste des Preisblatts und auf der Anmeldeseite.

Ersetzt wurde sie nicht durch nichts, sondern durch das, was daneben ohnehin
die staerkere Aussage war: Antwort am selben Werktag, ohne Warteschleife, kein
Callcenter. Nichts davon ist neu erfunden — jede dieser Zusagen stand vorher
schon auf derselben Seite.

Und der Grund, warum ein mailto-Link nichts tut

Der Betreiber hat in Chrome geklickt, und es passierte sichtbar nichts. Das ist
kein Fehler der Seite: ohne eingerichtetes Mailprogramm nimmt der Browser den
Klick entgegen und meldet niemandem, dass er ihn verworfen hat. Dieselbe
Beobachtung hatte schon den Anfrage-Knopf unter den Paketen von mailto auf eine
Seite umgestellt — die Adresse an anderen Stellen war dabei stehengeblieben.

Jetzt: Fusszeile und Sicherheitsseite fuehren auf die Anfrageseite, die Adresse
bleibt daneben als Text lesbar. Auf der Anfrageseite selbst waere ein Link
zurueck auf das Formular im Kreis gelaufen — dort steht die Adresse als Text
mit select-all, also mit einem Klick vollstaendig markiert.

Zwei mailto-Links bleiben mit Absicht stehen: das Impressum nennt die Adresse
als Pflichtangabe, und die "Coming soon"-Seite kann nicht auf die Anfrageseite
verweisen, weil PublicSiteGate genau diese Route dann ebenfalls durch die
"Coming soon"-Seite ersetzt — der Link liefe im Kreis.

43 Tests gruen (Anfrage, Preisblatt, Sprachparitaet, Startseite).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 00:17:36 +02:00
nexxo c062776a5b Die Anfrageseite lief unter der Kopfleiste durch
Die Kopfleiste des Layouts ist fixed und schwebt: 56 px hoch, 16 px vom Rand,
also rund 72 px hoch. Die Seite begann mit pt-14 — 56 px — und schob ihre
Ueberschrift damit zwangslaeufig darunter. Sie haelt jetzt denselben Abstand
wie die Startseite, und dieser Abstand ist der Grund fuer die Zahl, nicht
Geschmack.

Dabei zwei weitere Dinge behoben, die beim Nachsehen auffielen: die Seite
brachte ein zweites <main> mit, obwohl das Layout bereits eines stellt (pro
Dokument darf es nur eines geben), und ein `sm:col-span-2` am x-ui.input haette
nichts getan — das Bauteil reicht mitgegebene Klassen an das INPUT weiter, nicht
an seinen Rahmen, und der Rahmen ist das Rasterkind.

Und die Seite steht jetzt in der Formensprache der Startseite statt daneben:
derselbe warme Schimmer hinter der Ueberschrift, Haarlinien-Karten,
lbl-Etiketten, gestaffelte Einblendungen, die echten Bauteile fuer Feld und
Knopf statt handgeschriebener Klassen.

Neu daneben: was nach dem Absenden passiert. Das ist die Frage, die jeder hat,
der ein Formular vor sich sieht, und die ein Formular allein nicht beantwortet
— bei Enterprise steht dort, dass Speicher, Nutzerzahl und Sicherung die
Maschine ergeben, sonst die Antwort am selben Werktag. Versprochen wird nichts,
was nicht schon vorher dastand.

Am Bildschirm nachgesehen, nicht nur im Test: Ueberschrift frei unter der
Kopfleiste, zwei Spalten ab lg, Formular und Spalte in einer Flucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 00:04:03 +02:00
nexxo 1207182662 Ein angehefteter Host umging die Reservierung
Heftet ein Betreiber eine geparkte Bestellung an einen Host, prüfte der
Anheft-Zweig nur die Kapazität — nicht, ob die Maschine jemand anderem
reserviert ist. Die frisch gebaute Reservierung ließ sich damit über einen ganz
normalen Weg durch die Oberfläche aushebeln, denn das Auswahlfeld auf der
Kapazitätsseite bot weiterhin alle aktiven Hosts an.

Der Schritt stellt jetzt dieselbe Bedingung wie die freie Platzierung —
unreserviert oder dem Kunden dieser Bestellung reserviert — über
Host::scopeUnreserved(), statt sie ein zweites Mal zu formulieren. Ein Pin auf
eine fremde Maschine wird nicht umgeleitet, sondern wartet
(awaiting_pinned_host): zu korrigieren ist die Wahl, nicht die Maschine.

Und die Ursache eine Ebene höher: das Auswahlfeld stellt fremd reservierte
Hosts gar nicht mehr zur Wahl, pin() lehnt sie zusätzlich ab, und ein Pin, der
seit dem Anheften reserviert wurde, sagt das in der Zeile — ein Auswahlfeld,
das dann kommentarlos wieder "Automatisch" zeigte, verschwiege, warum die
Bestellung weiter wartet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:53:11 +02:00
nexxo da11d70037 Der Deckel hielt außerhalb der Transaktion, das Fenster versprach einen Block zu viel
Zwei Befunde der Codex-Durchsicht an derselben Grenze — höchstens drei Blöcke
je Vertrag.

Die Mengenprüfung stand in __invoke(), also außerhalb der Buchungstransaktion,
und für Speicher wurde der Vertrag absichtlich nicht gesperrt. Zwei
Speicher-Bestellungen desselben Vertrags nahe der Grenze lasen damit beide
denselben alten Stand und fügten beide ein; der eindeutige Index über
(order_id, addon_key) greift dabei nicht, weil es zwei verschiedene
Bestellungen sind. Die Prüfung zählt jetzt in book(), unter der Sperre — und
die vorhandene Sperre gilt zusätzlich für gedeckelte Module, statt eine zweite
daneben zu nehmen. Der Idempotenz-Kurzschluss bleibt davor: eine wiederholte
Zustellung derselben Bestellung bekommt ihre Buchung zurück, statt ausgerechnet
am ausgefüllten Deckel zu scheitern (dieselbe Reihenfolge wie bei der
Kapazitätsprüfung).

Und im Bestätigungsfenster machte max(1, min($max, $packs)) aus null buchbaren
Blöcken wieder einen — dieselbe Falle, die in Billing::purchase() schon behoben
war. Bei null buchbaren Blöcken zeigt das Fenster jetzt die Absage in dem Satz,
den der Kunde zu dieser Grenze überall sonst liest, statt einen Kauf
anzubieten, den purchase() danach ablehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:53:00 +02:00
nexxo 055eb90876 Anfrage als Seite statt mailto: der Knopf tat auf den meisten Geraeten sichtbar nichts
tests / pest (push) Failing after 10m7s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Has been skipped Details
2026-08-01 18:31:50 +02:00
nexxo 7e215b9c50 Die Website hatte gar kein Alpine: elf x-show taten nichts, darunter der Preis-Umschalter 2026-08-01 18:13:55 +02:00
nexxo 6314bb60fb Schalter je Paket, ehrliche Statusanzeige, Reste der alten Leiter
Die Umschaltmigration verglich beim Erkennen eines bereits umgeschalteten
Bestands nur Kontingent und Platte. Bei Intern waren beide schon vorher
richtig (5/20 GB), also hielt sie das Paket für erledigt und ließ RAM, Kerne
und Plätze auf ihren alten Werten stehen (1024 MB/1 Kern/5 statt 4096 MB/2/3).
Die Prüfung vergleicht jetzt alle neun Vorgaben; eine zweite, kleine Migration
hebt einen Bestand nach, auf dem die erste schon lief, und tut nichts auf einer
Neuinstallation.

Die Statusanzeige unterschied bisher nicht zwischen "kein Angebot" und "läuft,
aber nicht im Laden" — ein internes Paket zeigte "Nichts verfügbar" in der
Liste und "Im Verkauf" auf der Versionsseite darunter. Vier Zustände statt
zwei, mit fester Reihenfolge: der Notausschalter (sales_enabled) schlägt die
Konsolen-Kennzeichnung (internal).

Ein zweiter Schalter je Paketfamilie nimmt sie aus dem Preisblatt, ohne sie
unverkäuflich zu machen — nach dem Vorbild des vorhandenen Verkaufsschalters,
ohne Bestätigungsmodal, weil reversibel. Enterprise wechselt von
sales_enabled=false (weder käuflich noch verschenkbar) auf internal=true, wie
das Testpaket.

Dazu die liegengebliebenen Zahlen der alten Leiter in Produktattrappe,
Mail-Vorschau, Fabrik-Vorgaben und Seedern, sowie eine Testzusicherung, die auf
eine wandernde ID statt auf den berechneten Wert hätte treffen können.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:53:06 +02:00
nexxo f8d0c2c353 Merge branch 'claude/angry-golick-187172' into feat/neue-pakete 2026-08-01 16:27:13 +02:00
nexxo 4ad1828f27 Ein reservierter Host gehoert seinem Kunden
placeableIn() nahm jeden aktiven Host im Rechenzentrum, und hasRoomFor() sowie
largestPlaceableGb() zaehlten eine exklusiv verkaufte Maschine obendrein mit —
der Shop versprach damit Platz, der bereits vergeben war. Ohne diese Markierung
ist 'eigener Server' ein Satz im Angebot und nirgends eine Tatsache.

hosts.reserved_for_customer_id (nullable, ueberlebt den Kunden) markiert die
Maschine; Host::placeableIn() und HostCapacity zaehlen sie nur noch fuer den
eigenen Mieter oder gar nicht mehr zum allgemeinen Bestand. Auf der
Host-Detailseite kann ein Operator reservieren und wieder loesen — Loesen
laeuft ueber ein eigenes Bestaetigungsmodal (R23), das selbst nichts aendert,
sondern nur an HostDetail::releaseReservation() zurueckmeldet. Host-Liste und
Kapazitaetsseite weisen eine reservierte Maschine als solche aus, statt sie
kommentarlos verschwinden zu lassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:06:14 +02:00
nexxo 1692fd3977 Enterprise wird angefragt, nicht ausgepreist
Ab 500 GB ist es eine eigene Maschine — im Shop faende placeableIn() auf 388 GB
vergebbarem Platz keinen Host, und der Kunde erfuehre das nach der Zahlung.
Der Anfrage-Block erscheint als vierter Bestandteil neben der
Vergleichstabelle, sobald PlanCatalogue::sellable() Enterprise nicht mehr
liefert; die Familie selbst bleibt bestehen, nur sales_enabled entscheidet
(gesetzt wird das erst in Task 9).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:48:41 +02:00
nexxo 3b5dee8f0d Ein Ausweg wird nur angeboten, wenn er auch buchbar ist
DowngradeCheck baute seine Empfehlung aus packsToCover(), und die rechnet
bloss. BookAddon lehnt aus ZWEI Gruenden ab - der Deckel bei drei Bloecken
und der Ausschluss von Enterprise -, und die Empfehlung kannte keinen davon.
Solange ein Block 100 GB brachte, war das unerreichbar; seit er 20 GB bringt,
landet jede Ueberschreitung ueber 60 GB dort.

Business -> Team mit 600 GB belegt bot "5 x Zusatzspeicher buchen (+100 GB)"
an. Das Modal klemmte still auf drei, den vierten haette BookAddon abgelehnt,
und der Kunde stand nach 45 Euro im Monat genau dort, wo er vorher stand - auf
der Karte, die sein Abo billiger machen sollte. Enterprise -> Business empfahl
Bloecke, die es fuer dieses Paket ueberhaupt nicht gibt.

AddonCatalogue::bookableQuantity() antwortet jetzt auf beide Gruende. Es gab
einem Enterprise-Vertrag "3" auf die Frage, die sein eigener Docblock stellt.

DowngradeCheck stellt diese eine Frage und klemmt daran: `packs` ist die
gebrauchte Zahl nur, wenn der Vertrag sie auch buchen darf, sonst null - und
dann traegt `short`, was nach allen buchbaren Bloecken uebrig bliebe, gemessen
an DEREN Restmenge statt am Deckel, damit ein Kunde mit einem Block nicht mehr
zu loeschen bekommt als noetig. Kein halbes Angebot: eine Dauerbuchung, die
den Wechsel trotzdem nicht freigibt, ist kein Ausweg, sondern der ausgegraute
Knopf mit Preisschild.

packsToCover() bleibt reine Arithmetik, mit einem Kommentar, der sagt warum:
die kaufmaennische Grenze steht in AddonCatalogue, und ein Klemmen an dieser
Stelle zoege eine Vertragsabfrage in jeden Kontingent-Schritt und ins Portal,
die beide keine Verkaufsfrage stellen.

Der Knopf haengt schon an `packs > 0` und verschwindet von selbst; der Satz
wechselt auf downgrade_escape.capped, der die verbleibende Luecke nennt und
nicht den Grund - "hoechstens drei Bloecke" waere im Enterprise-Fall falsch,
wo es gar keine gibt.

Zwei bestehende Tests hingen an 600 und 800 GB aus der 100-GB-Zeit, beide
inzwischen nicht mehr deckbar; einer haette das Modal gesucht, das die Karte
zu Recht nicht mehr oeffnet. Auf 550 und 560 GB umgestellt, wo sie das pruefen,
wofuer sie geschrieben wurden.

Dazu ein Test, der belegt statt annimmt, warum das max(1, min(...)) in
ConfirmBookStorage stehen bleiben darf: ein Modal ist per openModal direkt
erreichbar, aber bookStoragePacks() legt weder am Deckel noch bei Enterprise
eine Bestellzeile an. Die bestehenden Tests deckten purchase() ab, nicht diese
Weiterleitung.

Voller Testlauf: 2405 bestanden. Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:40:26 +02:00
nexxo c3c05ff9f8 Interne Pakete verschwinden aus dem Laden und bleiben in der Konsole
sales_enabled waere der falsche Hebel gewesen: es schaltet auch das
Verschenken ab. Das Testpaket ist fuer Abnahmelaeufe da und muss verschenkbar
bleiben, also zwei Felder fuer zwei Fragen. Der Checkout lehnt einen internen
Schluessel ausdruecklich ab, ueber denselben Fang wie einen unbekannten — eine
URL ist keine Liste, und die Antwort darf keinen Unterschied verraten.

GrantPlan las bislang dieselbe sellable()-Liste wie Preisblatt und Warenkorb,
sowohl fuer sein Dropdown als auch fuer die Validierung des Formularfelds —
ein interner Schluessel waere dort ebenso abgelehnt worden wie im Checkout,
und das Verschenken haette sein einziges Tor verloren. PlanCatalogue bekommt
deshalb grantable() als zweiten Leser derselben Abfrage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:31:46 +02:00
nexxo 11ba7ee34f Hostdetailseite: die Adresse steht neben den IPs und ist ein Klick zur Proxmox-Oberflaeche 2026-08-01 14:12:03 +02:00
nexxo c6ead1afc9 Deckel haelt auch am Rand: Warenkorb und Bestaetigungsdialog
Zwei Luecken aus der Durchsicht von Task 2, beide am selben Rand: genau am
erreichten Deckel.

purchase() klemmte $lines mit max(1, min(..., bookableQuantity, ...)) — bei
bookableQuantity=0 floort das auf eine Bestellzeile statt auf keine, und der
Speicher-Knopf war bedingungslos gerendert. Jetzt bricht purchase() fuer
type=storage frueh ab, wenn AddonCatalogue::quantityRefusal() ablehnt, und der
Knopf verschwindet zugunsten des Ablehnungssatzes.

ConfirmBookStorage klemmte an der absoluten Grenze (3), nicht an der
Restmenge — ein Vertrag mit einem gebuchten Block haette drei weitere
versprochen bekommen, obwohl nur zwei noch buchbar sind. Das Modal loest jetzt
seinen eigenen Vertrag auf (wie ConfirmCancelAddon und ConfirmRevokeSeat) und
klemmt an bookableQuantity().

Beide Faelle waren ungetestet; zwei neue Tests in StoragePackLimitTest.php
belegen sie und wurden vor dem Fix gegen den alten Stand als rot verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:50:07 +02:00
nexxo d62a2c8ff8 Hostnamen vergibt CluPilot: ein Name statt zweier, und der Zaehler ueberlebt das Loeschen
HostName::claim() haengt den Namen jetzt an die Rechenzentrums-Zeile
(next_host_number), nicht an MAX(hosts.name)+1: der Zaehler ueberlebt so
das Loeschen des zuletzt angelegten Hosts. hosts.dns_name faellt weg -
name ist ab jetzt der einzige Name, den Konsole, DNS, /etc/hosts und
Proxmox-Node teilen. RegisterHostDns veroeffentlicht nur noch, was
StartHostOnboarding beim Anlegen vergeben hat, statt selbst zu
nummerieren; PrepareBaseSystem baut den FQDN ueber HostName::fqdn()
statt ueber die nirgends konfigurierte clupilot.net.

Zwei Testdateien ausserhalb der Aufgabenliste (DatacenterTest,
HostTakeoverPageTest) setzten ->set('name', ...) auf HostCreate, das
Feld jetzt aber nicht mehr hat - im vollen Testlauf nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:45:43 +02:00
nexxo 55b749c129 Release v1.3.94 — die Kurven zeigen wieder, wie ausgelastet der Host ist
tests / pest (push) Failing after 9m19s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Drei Punkte des Besitzers, und der dritte hat den eigentlichen Fehler
freigelegt.

Die Verlaufslinien sahen komisch aus, weil sie auf ihr EIGENES Minimum und
Maximum skalierten. Ein Host mit 0,2 % CPU, der zwischen 0,1 und 0,3 schwankt,
zeichnete damit einen Seismographen über die volle Höhe — und widersprach flach
der Zahl direkt daneben. Dieselbe Regel stand im Chart.js-Entwurf schon
ausformuliert ("eine automatisch skalierte Achse lässt 3 % wie eine Wand
aussehen") und ging beim Umstieg auf die Kacheln verloren.

x-ui.spark bekommt deshalb `min`/`max`. Ohne Angabe bleibt alles wie bisher —
das ist, was jeder bestehende Aufrufer übergibt. Die Prozent-Kacheln geben
0 und 100 mit, die Netz-Kacheln nur die 0, weil MiB/s keine natürliche
Obergrenze haben. Ein untätiger Host zeichnet jetzt vier ruhige Linien statt
zweier wogender und zweier flacher — vorher wirkte CPU (0,2 %) belebter als RAM
(3,6 %), also genau verkehrt herum.

Dazu: Werte außerhalb der Grenzen werden geklemmt statt aus dem Kasten
gezeichnet, die Fläche ist ein Verlauf statt einer harten Kante, und die
Linien sind mit 120×40 statt 80×32 lesbar.

Ich hatte max=100 zwischendurch selbst verworfen, weil es "zu tot" aussah — auf
einem Bild, auf dem die Füllung wegen eines CSS-Fehlers gar nicht gezeichnet
wurde. Ein Vergleich mit einem kaputten Bild. Der Fehler: `.spark path
{ fill: none }` schlägt als CSS-Regel das Präsentationsattribut fill="url(#…)".
`fill: none` gehört an die Linie, nicht an jeden Pfad.

Die Ausstattung ist wieder einzeilig. Sechs Felder, sechs Spalten — und die
Bau-Kennung steht nur noch im Titel: als eigene Zeile zwang sie die ganze Tafel
in eine zweite Reihe, für eine Zeichenkette, die fast niemand liest.

Und die Wartezeit: die Seite stößt beim Öffnen eine Sammlung an, wenn noch
keine Messwerte da sind, statt bis zum nächsten minütlichen Lauf leer zu
bleiben. Ein leerer Kasten liest sich als "kaputt", nicht als "gleich". Der Job
ist ShouldBeUnique, zwei geöffnete Seiten reihen also keine zwei ein.

Codex-Befund dazu (P2): das galt auch für Hosts, die der Sammler ohnehin
überspringt — ein Host mitten in der Übernahme hat keinen Token, und seine
Seite hätte die ganze Flotte abgeklappert und die eigenen Kacheln trotzdem leer
gelassen. Wer gesammelt wird, steht jetzt einmal am Modell (Host::collectable),
gelesen von Job und Seite. Zwei Fassungen dieser Frage waren genau der Grund.

Geprüft: 2307 Tests grün, Pint sauber, Codex ohne Befund. Der Test für den
Maßstab prüft jetzt auch die echten Aufrufstellen und nicht nur das Bauteil —
gegengeprobt durch Entfernen von max=100, dann fällt er. Im Browser beide
Fälle angesehen: ruhiger Host vier flache Linien, ausgelasteter Host lesbare
Form, null Konsolenfehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:56:10 +02:00
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 2f15d1e7a4 Release v1.3.84 — VPN-Zugänge nach Personen und Hosts getrennt
tests / pest (push) Failing after 9m6s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Personen und Hosts standen gemischt untereinander, nach Verbindungszustand
sortiert — ein Host-Zugang konnte also zwischen zwei Mitarbeitern stehen.
Beide sind Peers im selben Netz und haben dieselben Messwerte, aber es sind
zwei verschiedene Fragen: "wer von uns ist im Netz" und "welche Maschinen
hängen dran". Wer die eine stellt, liest die Antworten der anderen als
Rauschen.

Jetzt zwei Gruppen mit Überschrift und Anzahl. Eine Gruppe ohne Einträge wird
gar nicht erst gezeichnet — auf einer frischen Installation stünde sonst
"Hosts" über einer Lücke, bevor je einer angelegt wurde.

Getrennt wird nach der geladenen host-Beziehung, nicht nach `kind`: die
Plakette in der Zeile fragt dasselbe, und wer einen "Host-Zugang" liest, soll
ihn auch unter den Hosts finden. Ein adoptierter Peer (kind=system) mit
host_id gehört zu den Hosts, obwohl seine Art etwas anderes sagt.

Die Zeile selbst ist nach resources/views/components/admin/vpn-peer-row.blade.php
gewandert. Sie zweimal hinzuschreiben hätte geheissen, sie ab dem nächsten
Knopf an zwei Stellen zu pflegen — und die zweite fällt erst auf, wenn jemand
einen Host-Zugang sucht und ihn anders aussehen sieht als seinen eigenen.

Die Plakette bleibt trotz der Überschrift daneben: eine Zeile wandert beim
Suchen aus ihrer Überschrift heraus, und dann steht sie ohne sie da.

Codex: keine Befunde. 2237 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 02:34:15 +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 4f037c8264 Mahnwesen: Einstellungen und die zwei Eingriffe von Hand 2026-07-31 22:43:52 +02:00
nexxo fb1ef3fc63 Rollen: aufklappbare Karten, gruppierte Rechte, Knopf im Raster 2026-07-31 22:39:46 +02:00
nexxo a64d41639b Mahnwesen: die sechs Kundenmails 2026-07-31 22:31:04 +02:00
nexxo 29c2718235 R20: Begruendung im Modal, und der Waechter sieht jetzt auch Listenzeilen 2026-07-31 22:09:05 +02:00
nexxo 7f7b654fcb Zahlungsprobleme: geplatzte Kaeufe aufzeichnen, beide Arten an einer Stelle 2026-07-31 21:58:52 +02:00
nexxo 93e848d3f9 Rollen und Rechte in der Konsole zuweisbar 2026-07-31 21:46:22 +02:00