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>
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>
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>
- 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>
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>
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>
The page was a wall. Six paragraphs of procedure stacked above a form, so the
thing the page actually asks for — four fields — sat underneath an essay about
what would happen afterwards. It answered everything and showed nothing.
It is now built the way the settings page is built, because that page was
rebuilt for the same reason and there is no case for a second idiom: an eyebrow,
a title, a sticky rail on the left and panels of rows on the right.
The rail carries the six steps as two-word labels rather than paragraphs. That
is what the question at this moment actually is — where am I, how much is left —
and it fits in 232 pixels. The numbers carry the state; a tick beside them would
be a second sign for one statement, and a number can be counted.
What a step MEANS now appears in the row where it is due, not six times in
advance. The two provider steps get a panel of their own above the form, because
they have to be done before you save: the one-time code starts expiring the
moment you do. Everything after the form waits until there is something to say.
After saving the command becomes the page. Full width, its own framed block with
the warning in the header strip rather than floating above, and the two
remaining steps below it as ordinary rows.
Two token bugs went with it. `bg-canvas` does not exist — it was a class that
compiled to nothing, which is part of why the block looked wrong. And
`text-accent` on white is 2.9:1; the config says in as many words to use
accent-text for anything read, so the current step number does.
Also fixed, and it would have broken every takeover in production: PublicSiteGate
is appended to the whole web group, so while the site is hidden a server in a
rescue system fetching the archive would have received the 503 placeholder and
piped it into tar. The operator would have seen an unpack error on a machine
only reachable through the provider's console, with nothing pointing at a switch
in the admin area — and they are by definition neither on a management network
nor signed in, since leaving that state is the whole point. Exempted by ROUTE
NAME, not by hostname: the docblock rightly warns that exempting by host means
trusting a header the caller picks, but that warning is about the entire portal.
Behind these two routes are documents that are public anyway and an archive that
404s without a valid one-time code.
2026 tests pass, assets build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit shipped the guide AFTER the host was created. Its first two
steps happen at the provider — order the machine, boot the rescue system — and
whoever reads them there has the one-time code already in the clipboard with its
24-hour window running. The instructions were correct and in the wrong place,
which is its own kind of wrong.
Six steps now, not three, and the two provider ones are named rather than
assumed. They are marked "first" so the list does not read as something you can
start at the top of and work down: step 2 is half an hour of waiting, and doing
it inside a running code is exactly the mistake the page should prevent.
All six are visible the whole time, including the done ones and the ones still
to come. A guide that shows only the current step cannot answer "how much is
left", which is the question somebody has while a server they are paying for
sits in a rescue system.
It is on the hosts list too, collapsed. Somebody standing there may not have
ordered the machine yet — and that is step 1. Requiring them to click "add host"
to find out what the procedure is puts the answer behind the action it describes.
One component, two places, so the archive address in the instructions is the same
string the command line will carry. Two copies of that would drift, and the
difference would surface on a server that has already been paid for.
The step-4 command box moved into the guide rather than sitting beside it, so
after creating a host the whole procedure stays on screen. Somebody stuck at
step 5 should not be looking at a page that has become a single box.
1991 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel had been built by reading the template and believing the result.
Every round of review found the same class of defect, so this round the
template was rendered in a browser and measured with getComputedStyle, and the
implementation measured the same way — two number columns instead of an
opinion.
That immediately settled a disagreement: a review of the template's SOURCE said
the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins.
Brought to the template's figures: label 11.5px (it was 11px in three separate
places, which is how it drifted — it is now one .lbl rule), value tracking
-0.02em, unit weight 450, metric row 6px above and 14px between, page head
centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the
h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px.
The shared button now takes its height from min-height (40px) rather than
vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button
keeps its height whatever sits inside it, icon, spinner or bare text.
Also here, from the same round of review:
- The chart's blue border was Tailwind's own `ring` utility colliding with a
component class of the same name. Renamed to `.metric-ring`. This was
dismissed once as a screenshot artefact; it was real.
- Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended
to 23 of them. The template's h1 is 30px.
- The users table has its actions back — edit, suspend, lock and delete — for
every seat that is not the owner, with the owner refused in the action itself
and not merely hidden in the markup.
DemoCustomerSeeder writes one complete customer as real rows: instance,
subscription, six seats across four roles, a backup, current-period traffic and
thirty days of samples with a deliberate wobble and one day at 286/288 checks.
Nothing in the panel is drawn from a fixture any more, so anything missing
shows up as missing. Removing the demo is one deletion.
Verified: 607 tests, and a Codex comparison of the two measurement sets —
"a person would call them the same design", the only difference a 1px gap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The breadcrumb said Übersicht on every page. The sidebar and the breadcrumb
each built their own copy of the structure, so the layout had no way to know
which entry was current and fell back to the first one. Navigation::portal()
and ::console() are now the single definition, and currentLabel() matches on the
route name — which matters for the console, whose PATH changes between
host-bound and fallback mode while its route names do not.
The datacentre name is out of everything a customer sees, for the third time
and now at the source rather than in a template. Falkenstein is how an
operator places an instance; a customer's processing record names the
jurisdiction, and putting the building in customer copy means editing that copy
every time the estate grows.
Also swept the views onto the new scale: --text-faint is 2.8:1 and a decoration
token, but it was carrying table headers, hints and timestamps — text people
have to read. All of it moved to --muted, which is 5.0:1 and passes AA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Separate admin console at /admin, gated to is_admin users (EnsureAdmin middleware
+ migration/seed; a plain customer user is seeded to prove the gate). Distinct dark
aesthetic achieved purely by token scoping: .theme-admin overrides every CSS design
token to a dark graphite / signal-orange palette, so ALL shared components (button,
card, badge, table, stat, chart) render dark with zero new markup (R3). Chart island
now reads tokens from its own element, so charts are theme-aware.
Sections (each full-page class-based Livewire, English routes R13, localized DE/EN):
- Overview: fleet KPIs, fleet-growth line, host-load bars, MRR bars, active runs, alerts.
- Customers: table + plan doughnut. Instances: fleet table (vmid/host/storage).
- Hosts: capacity cards (storage/CPU bars). Provisioning: runs table + live stepper.
- Revenue: MRR/ARR/ARPU/churn KPIs, MRR line, plan doughnut, recent payments.
- Locale-aware month labels/currency (Carbon/Number).
18 new Pest tests (guest redirect / non-admin 403 / admin render per section) → 44
green. R12 browser: all six admin pages HTTP 200, ZERO console errors (Chart.js dark).
Codex (R15) — clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>