+{{-- Die Bühne liegt ÜBER dem Terminal, nicht daneben.
-
- {{-- Schriftgrafik, keine Bilddatei — deshalb `role="img"` mit einem
- lesbaren Namen statt `aria-hidden`: ein Screenreader soll "CluPilot"
- hören, nicht die einzelnen Zeichen der Zeichnung. --}}
-
- ___ _ ___ _ _ _
- / __| |_ _| _ \(_) |___| |_
- | (__| | || | _/| | / _ \ _|
- \___|_|\_,_|_| |_|_\___/\__|
-
-
{{ __('hosts.terminal.connecting', ['host' => $host->name]) }}
-
+ Bis hierher waren es zwei Geschwister, von denen abwechselnd eines
+ `hidden` trug: erst der Vorspann, dann der Schirm. Das trug genau einmal —
+ beim Aufbau. Für alles danach fehlte die Rückfahrkarte, und was der
+ Betreiber beim Abbruch sah, war eine rote ANSI-Zeile mitten in der eigenen
+ Ausgabe: die einzige Stelle im Produkt, an der eine Meldung nicht gestaltet
+ war, sondern getippt.
+
+ Als Überlagerung kann dieselbe Bühne dreimal auftreten — beim Verbinden,
+ beim Abbruch, beim Ende — und die Sitzung darunter bleibt stehen, statt
+ gelöscht zu werden. Was auf ihr steht, kommt aus den Sprachdateien; die
+ Meldungen, die erst der Browser kennt, reisen als JSON in
+ `data-stage-messages` mit, damit auch sie dort stehen und nicht im Skript. --}}
+@php
+ // Ein Grund, den schon der Server kennt (kein Schlüssel hinterlegt, Host
+ // noch nicht übernommen), steht sofort auf der Bühne. Sonst beginnt sie mit
+ // dem Vorspann. Derselbe Bau, anderer Text, anderer Ton.
+ $stageTitle = $problem !== ''
+ ? __('hosts.terminal.problem.'.$problem.'.title')
+ : __('hosts.terminal.stage.connecting.title', ['host' => $host->name]);
+
+ $stageNote = $problem !== ''
+ ? __('hosts.terminal.problem.'.$problem.'.note')
+ : __('hosts.terminal.stage.connecting.note');
+
+ // Was der Browser erst später braucht. Welcher Schlüssel wann gilt, steht
+ // in resources/js/terminal.js neben den Schließcodes der Brücke.
+ $messages = [
+ 'ended' => [
+ 'title' => __('hosts.terminal.stage.ended.title'),
+ 'note' => __('hosts.terminal.stage.ended.note', ['host' => $host->name]),
+ ],
+ 'lost' => [
+ 'title' => __('hosts.terminal.stage.lost.title'),
+ 'note' => __('hosts.terminal.stage.lost.note', ['host' => $host->name]),
+ ],
+ 'rejected' => [
+ 'title' => __('hosts.terminal.stage.rejected.title'),
+ 'note' => __('hosts.terminal.stage.rejected.note'),
+ ],
+ 'unreachable' => [
+ 'title' => __('hosts.terminal.stage.unreachable.title', ['host' => $host->name]),
+ 'note' => __('hosts.terminal.stage.unreachable.note', ['host' => $host->name]),
+ ],
+ ];
+@endphp
+
{{-- wire:ignore: xterms eigener DOM-Baum liegt in einem von Livewire
verwalteten Baum. Heute hat die Komponente keine Aktion, die einen
Re-Render auslöst — aber das erste `wire:`-irgendwas auf dieser Seite
würde xterm sonst beim nächsten Umlauf löschen. --}}
-
+
+
+
+
+ {{-- Schriftgrafik, keine Bilddatei — deshalb `role="img"` mit einem
+ lesbaren Namen statt `aria-hidden`: ein Screenreader soll "CluPilot"
+ hören, nicht die einzelnen Zeichen der Zeichnung.
+
+ Vollblöcke, nicht `_ / \ |`. Die erste Fassung war eine gewöhnliche
+ Figlet-Zeichnung, und die funktioniert nur in einer echten
+ Terminalschrift: sie setzt darauf, dass der Unterstrich einer Zeile
+ den Strich der nächsten berührt. In IBM Plex Mono sitzt der
+ Unterstrich tiefer — bei enger Zeilenhöhe verschmierte das Wort, bei
+ weiter zerfiel es in Einzelteile. Beides im Browser durchgemessen,
+ beides unlesbar. `█` füllt seine Zelle ganz aus und stapelt deshalb
+ in jeder Schrift sauber.
+
+ Immer Akzentfarbe, auch im Fehlerfall: Dunkelrot auf Fast-Schwarz
+ hatte kaum Kontrast, und eine Wortmarke, die ihre Farbe wechselt, ist
+ ohnehin keine Statuslampe — was los ist, sagt die Zeile darunter.
+
+ 8px am Telefon: bei 11px ist die Zeichnung 403px breit und schöbe auf
+ einem schmalen Schirm die Seite zur Seite. --}}
+
██████ ██ ██ ██ ██████ ██ ██ ██████ ████████
+██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
+██ ██ ██ ██ ██████ ██ ██ ██ ██ ██
+██ ██ ██ ██ ██ ██ ██ ██ ██ ██
+ ██████ ███████ ██████ ██ ██ ███████ ██████ ██
+
+ {{-- aria-live: die Bühne tauscht ihren Text aus, ohne dass die Seite neu
+ lädt. Ohne diesen Hinweis erführe ein Screenreader vom Abbruch
+ nichts — für ihn stünde das Fenster weiter beim Vorspann. --}}
+
+
{{ $stageTitle }}
+
{{ $stageNote }}
+
+
+ {{-- Neu laden statt neu verbinden: das Ticket gilt dreißig Sekunden und
+ genau einmal, ein zweiter Anlauf mit demselben wäre keiner. Der
+ Knopf ist beim Vorspann verborgen und kommt erst, wenn ein neuer
+ Versuch überhaupt etwas ändern kann — bei „kein Schlüssel
+ hinterlegt" ändert er nichts, dort bleibt er weg. Was er tut, hängt
+ terminal.js an; ein `onclick` im Markup wäre Verhalten an der
+ Stelle, an der sonst nur Gestalt steht.
+
+ `hidden` sitzt am Wrapper, nicht am Knopf: `x-ui.button` bringt
+ `inline-flex` mit, und beide Klassen haben dieselbe Spezifität —
+ wer gewinnt, entschiede dann die Reihenfolge im Stylesheet. Dieselbe
+ Falle wie bei `size-4`/`size-5` in R18, und sie ist hier mit einem
+ zusätzlichen Element billiger umgangen als nachgewiesen. --}}
+
$problem !== 'unknown'])>
+
+ {{ __('hosts.terminal.stage.retry') }}
+
+
+
diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php
index a299347..4f7e02e 100644
--- a/resources/views/livewire/admin/hosts.blade.php
+++ b/resources/views/livewire/admin/hosts.blade.php
@@ -150,22 +150,27 @@
{{ __('hosts.status.'.$host->status) }} |
- {{-- Ohne Tunneladresse oder Fingerabdruck kann keine Sitzung
- entstehen — TerminalTicket::issue() weist das mit einer
- RuntimeException zurück (Aufgabe 1). Ein Knopf, der das
- verlässlich auslöst, ist schlechter als gar keiner. --}}
+ {{-- Der Knopf steht auf JEDER Zeile, auch wenn dem Host noch die
+ Tunneladresse oder der Fingerabdruck fehlt.
+
+ Vorher verschwand er dann, mit der Begründung, ein Knopf, der
+ verlässlich in eine Ausnahme läuft, sei schlechter als gar
+ keiner. Das stimmte, solange die Seite dahinter mit einer
+ Laravel-Fehlerseite aufging. Sie tut es nicht mehr — sie
+ schreibt hin, was fehlt (`HostTerminal::mount()`,
+ `hosts.terminal.problem.*`). Damit ist der fehlende Knopf die
+ schlechtere Auskunft von beiden: er sah aus wie „hier gibt es
+ kein Terminal", nicht wie „hier noch nicht, und zwar deshalb". --}}
@can('hosts.manage')
- @if ($host->wg_ip && $host->ssh_host_key)
- {{-- href direkt am Knopf, nicht als drumherum: interaktiver
- Inhalt in einem Link ist ungültiges HTML, und Mittelklick/
- Statuszeile hingen sonst an einem Element, auf dem der
- Zeiger nie landet (components/ui/button.blade.php Zeile 6-9). --}}
-
- {{ __('hosts.terminal.open') }}
-
- @endif
+ {{-- href direkt am Knopf, nicht als drumherum: interaktiver
+ Inhalt in einem Link ist ungültiges HTML, und Mittelklick/
+ Statuszeile hingen sonst an einem Element, auf dem der
+ Zeiger nie landet (components/ui/button.blade.php Zeile 6-9). --}}
+
+ {{ __('hosts.terminal.open') }}
+
@endcan
|
diff --git a/tests/Feature/Admin/HostTerminalTest.php b/tests/Feature/Admin/HostTerminalTest.php
index 3a26a2d..b91c38b 100644
--- a/tests/Feature/Admin/HostTerminalTest.php
+++ b/tests/Feature/Admin/HostTerminalTest.php
@@ -161,7 +161,7 @@ it('zeigt den Vorspann und reicht das Ticket weiter, aber niemals den Schlüssel
// Der Vorspann steht da, bevor irgendetwas verbindet.
expect($html)->toContain('CluPilot')
- ->and($html)->toContain('data-terminal-splash')
+ ->and($html)->toContain('data-terminal-stage')
// Das Ticket selbst muss im Markup landen — sonst bewiese dieser Test
// trotz seines Namens nie, dass issue() etwas Brauchbares liefert.
// 64 Hexzeichen: TerminalTicket::issue() aus Aufgabe 1 bildet das
@@ -172,6 +172,39 @@ it('zeigt den Vorspann und reicht das Ticket weiter, aber niemals den Schlüssel
->and($html)->not->toContain($host->wg_ip);
});
+it('setzt in jede Meldung, die der Browser später zeigt, den Hostnamen wirklich ein', function () {
+ // Gefunden beim Hinsehen, nicht beim Testen: die Überschrift zum Abbruch
+ // stand als „:host antwortet nicht" auf dem Schirm — der Platzhalter war
+ // beim Übersetzen ohne Ersetzung geblieben.
+ //
+ // Ein Test, der `toContain(__('…title'))` schriebe, hätte das nie gefunden:
+ // er vergliche ":host" mit ":host" und wäre grün. Deshalb prüft dieser hier
+ // das Ergebnis — Name drin, Doppelpunkt-Platzhalter draußen.
+ $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
+
+ $html = $this->actingAs(admin(), 'operator')
+ ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
+ ->getContent();
+
+ // Die Meldungen reisen als JSON im data-Attribut mit; im HTML stehen die
+ // Anführungszeichen deshalb als ".
+ expect($html)->toContain('data-stage-messages=')
+ ->and($html)->not->toContain(':host');
+
+ // Und der Name steht wirklich drin — sonst wäre die Zeile oben auch dann
+ // grün, wenn gar keine Meldung mitkäme.
+ // `Str::between` schnitte bis zum LETZTEN Anführungszeichen der Seite —
+ // gebraucht wird das erste, also das Ende des Attributs. Innerhalb des
+ // Attributs steht ohnehin `"`, nie ein rohes Zeichen.
+ $messages = json_decode(html_entity_decode(
+ Str::before(Str::after($html, 'data-stage-messages="'), '"'), ENT_QUOTES
+ ), true);
+
+ expect($messages['unreachable']['title'])->toContain($host->name)
+ ->and($messages['ended']['note'])->toContain($host->name)
+ ->and($messages['lost']['note'])->toContain($host->name);
+});
+
it('bietet den Terminal-Knopf nur dem, der ihn drücken darf', function () {
$host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
@@ -199,15 +232,57 @@ it('prüft die Berechtigung vor dem Nachschlagen — eine erfundene UUID meldet
->assertForbidden();
});
-it('zeigt den Terminal-Knopf nicht für einen Host ohne Tunneladresse oder Fingerabdruck', function () {
- // Mitten im Onboarding: die Adresse steht noch nicht, der Fingerabdruck
- // auch nicht (Host::factory() ohne active()). Ein Knopf, der hier
- // erscheint, führte in genau die unbehandelte Ausnahme, die issue() oben
- // bewusst wirft — der Knopf bleibt deshalb weg, statt in eine Sackgasse
- // zu führen.
- Host::factory()->create();
+it('zeigt den Terminal-Knopf auch für einen Host, der noch nicht so weit ist', function () {
+ // Mitten in der Übernahme: die Tunneladresse steht noch nicht, der
+ // Fingerabdruck auch nicht (Host::factory() ohne active()).
+ //
+ // Der Knopf verschwand hier einmal, weil die Seite dahinter mit einer
+ // unbehandelten Ausnahme aufging. Das tut sie nicht mehr — sie schreibt
+ // hin, was fehlt. Ein fehlender Knopf sähe aus wie „hier gibt es kein
+ // Terminal" statt „hier noch nicht, und zwar deshalb".
+ $host = Host::factory()->create();
$html = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent();
- expect($html)->not->toContain(__('hosts.terminal.open'));
+ expect($html)->toContain(route('admin.hosts.terminal', ['host' => $host->uuid]));
+});
+
+it('geht bei einem Host ohne Fingerabdruck nicht mit einer Fehlerseite auf, sondern sagt warum', function () {
+ // Das war der eigentliche Fehler: TerminalTicket::issue() warf, niemand
+ // fing es, und der Betreiber bekam Laravels Fehlerseite — Klassenname,
+ // Dateipfad, Zeilennummer, Quelltextauszug — in einem Fenster, das er über
+ // einen Knopf im eigenen Produkt geöffnet hatte.
+ $host = Host::factory()->create(['wg_ip' => null, 'ssh_host_key' => null]);
+
+ $html = $this->actingAs(admin(), 'operator')
+ ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
+ ->assertOk()
+ ->getContent();
+
+ expect($html)->toContain(__('hosts.terminal.problem.not_ready.title'))
+ // Ohne Grund kein Ticket: ein Fenster, das trotzdem eines bekäme,
+ // machte einen Socket auf und überschriebe den Satz oben mit einem
+ // zweiten, ungenaueren.
+ ->and($html)->toContain('data-ticket=""')
+ ->and($html)->not->toContain('RuntimeException');
+});
+
+it('sagt auch dann warum, wenn gar kein Flottenschlüssel hinterlegt ist', function () {
+ // Derselbe Weg, anderer Grund — und der andere Grund ist der, der ALLE
+ // Hosts auf einmal betrifft. Er darf nicht als „dieser Host ist noch nicht
+ // so weit" erscheinen, sonst sucht jemand am falschen Ende.
+ $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
+
+ // SecretVault::forget() fällt auf .env zurück, deshalb auch die
+ // Konfiguration leeren (Muster aus OnboardingChecksTest).
+ app(SecretVault::class)->forget('ssh.private_key');
+ config()->set('provisioning.ssh.private_key', '');
+
+ $html = $this->actingAs(admin(), 'operator')
+ ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
+ ->assertOk()
+ ->getContent();
+
+ expect($html)->toContain(__('hosts.terminal.problem.no_key.title'))
+ ->and($html)->not->toContain(__('hosts.terminal.problem.not_ready.title'));
});