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>
main
nexxo 2026-08-02 19:27:35 +02:00
parent c6403bf829
commit f64a564c40
9 changed files with 459 additions and 114 deletions

View File

@ -45,6 +45,12 @@ class HostTerminal extends Component
public string $ticket = '';
/**
* Warum hier kein Terminal entstehen kann ein Merkwort aus
* `TerminalTicket::blocker()`, oder leer, wenn nichts im Weg steht.
*/
public string $problem = '';
public function mount(string $host): void
{
$this->authorize('hosts.manage');
@ -54,7 +60,28 @@ class HostTerminal extends Component
/** @var Operator $operator */
$operator = auth('operator')->user();
$this->ticket = TerminalTicket::issue($this->record, $operator);
// Diese Seite wirft nicht. Ein Terminalfenster, das mit Laravels
// Fehlerseite aufgeht — Klassenname, Dateipfad, Zeilennummer,
// Quelltextauszug —, ist ein Bruch mitten im Produkt: der Betreiber hat
// einen Knopf gedrückt und bekommt einen Stacktrace. Was schiefgehen
// kann, steht in `blocker()` und wird unten in der Sprache dieses
// Fensters hingeschrieben.
$this->problem = TerminalTicket::blocker($this->record) ?? '';
if ($this->problem !== '') {
return;
}
// Der Fangzaun dahinter: `blocker()` kennt die Gründe, mit denen wir
// rechnen. Reißt `issue()` aus einem, mit dem niemand gerechnet hat
// (Redis weg, Tresor kaputt), ist die Antwort dieselbe — eine gestaltete
// Seite statt einer Fehlerseite —, nur ohne Versprechen, woran es lag.
try {
$this->ticket = TerminalTicket::issue($this->record, $operator);
} catch (\Throwable $e) {
report($e);
$this->problem = 'unknown';
}
}
public function render()

View File

@ -62,32 +62,56 @@ final class TerminalTicket
private const PREFIX = 'terminal:ticket:';
/**
* Was diesem Host gerade im Weg steht oder null, wenn nichts im Weg steht.
*
* Es gibt diese Methode, weil dieselbe Frage an zwei Stellen gestellt wird
* und nur eine davon eine Ausnahme gebrauchen kann. `issue()` unten muss
* scheitern: es ist die letzte Instanz vor dem Ticket. Die Seite dagegen
* will die Antwort VORHER wissen, um sie in ihrer eigenen Sprache
* hinzuschreiben, statt sie als 500er an den Betreiber weiterzureichen.
*
* Zurück kommt ein Merkwort, kein Satz: die Formulierung gehört in die
* Sprachdateien, nicht in eine Ausnahme. `null` heißt „nichts im Weg".
*/
public static function blocker(Host $host): ?string
{
// Ohne Schlüssel kann die Brücke sich nicht anmelden, und der Betreiber
// sähe nur einen Vorspann, der nie weicht.
if (blank((string) app(SecretVault::class)->get('ssh.private_key'))) {
return 'no_key';
}
// Derselbe Gedanke, für die beiden anderen Felder, ohne die keine
// sichere Sitzung entstehen kann. Eine fehlende Tunneladresse ergibt
// bloß ein Fenster, das nie verbindet — ärgerlich, aber harmlos. Ein
// fehlender Fingerabdruck ist das Gegenteil: er übergäbe die Prüfung an
// Code, der noch gar nicht existiert, und dessen naheliegendste Fassung
// ("kein Fingerabdruck im Ticket → nicht prüfen") eine ungepinnte
// Root-SSH-Sitzung im Tunnel wäre — genau das, was die Spec mit
// „Fingerabdruck geprüft" ausdrücklich ausschließt. Die Prüfung gehört
// hierher, wo der Host ohnehin schon feststeht, nicht in einen
// Container, der dem Ticket nur noch glauben kann.
if (blank($host->wg_ip) || blank($host->ssh_host_key)) {
return 'not_ready';
}
return null;
}
public static function issue(Host $host, Operator $for): string
{
// Die Prüfung steht in `blocker()`; hier bleibt nur, sie ernst zu
// nehmen. Der Wortlaut ist der von früher — er steht in Logs und in
// den Tests aus Aufgabe 1.
match (self::blocker($host)) {
'no_key' => throw new RuntimeException('Kein SSH-Schlüssel hinterlegt — ohne ihn kann keine Terminalsitzung entstehen.'),
'not_ready' => throw new RuntimeException("Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket."),
default => null,
};
$key = (string) app(SecretVault::class)->get('ssh.private_key');
// Lieber hier scheitern als ein Fenster, das aufgeht und schweigt: ohne
// Schlüssel kann die Brücke sich nicht anmelden, und der Betreiber sähe
// nur einen Vorspann, der nie weicht.
if (blank($key)) {
throw new RuntimeException('Kein SSH-Schlüssel hinterlegt — ohne ihn kann keine Terminalsitzung entstehen.');
}
// Derselbe Gedanke wie beim Schlüssel oben, für die beiden anderen
// Felder, ohne die keine sichere Sitzung entstehen kann. Eine
// fehlende Tunneladresse ergibt bloß ein Fenster, das nie verbindet —
// ärgerlich, aber harmlos. Ein fehlender Fingerabdruck ist das
// Gegenteil: er übergäbe die Prüfung an Code, der noch gar nicht
// existiert, und dessen naheliegendste Fassung ("kein Fingerabdruck
// im Ticket → nicht prüfen") eine ungepinnte Root-SSH-Sitzung im
// Tunnel wäre — genau das, was die Spec mit „Fingerabdruck geprüft"
// ausdrücklich ausschließt. Die Prüfung gehört hierher, wo der Host
// ohnehin schon feststeht, nicht in einen Container, der dem Ticket
// nur noch glauben kann.
if (blank($host->wg_ip) || blank($host->ssh_host_key)) {
throw new RuntimeException("Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket.");
}
$ticket = bin2hex(random_bytes(32));
Redis::connection('cache')->setex(self::PREFIX.$ticket, self::TTL_SECONDS, json_encode([

View File

@ -136,8 +136,52 @@ return [
'terminal' => [
'open' => 'Terminal',
'connecting' => 'Verbinde mit :host …',
'hint' => 'Öffnet ein eigenes Fenster mit einer Root-Sitzung auf diesem Host.',
// Was auf der Bühne steht — dem Bild, das über dem Terminal liegt.
// Beim Verbinden, beim Ende, beim Abbruch dasselbe Bild, anderer Text.
// Die vier unteren kennt erst der Browser; sie reisen als JSON mit.
'stage' => [
'connecting' => [
'title' => 'Verbinde mit :host',
'note' => 'Das Einmal-Ticket wird eingelöst und die Sitzung aufgebaut.',
],
'ended' => [
'title' => 'Sitzung beendet',
'note' => 'Die Verbindung zu :host wurde geschlossen. Ein neuer Anlauf öffnet eine frische Sitzung.',
],
'lost' => [
'title' => 'Keine Verbindung',
'note' => 'Zu :host kam keine Sitzung zustande. Läuft der Terminal-Dienst, und steht der Tunnel?',
],
'rejected' => [
'title' => 'Ticket nicht mehr gültig',
'note' => 'Ein Ticket gilt dreißig Sekunden und genau einmal. Dieses war abgelaufen oder schon eingelöst — ein neuer Anlauf stellt ein frisches aus.',
],
'unreachable' => [
'title' => ':host antwortet nicht',
'note' => 'Die Anmeldung über den Tunnel ist gescheitert: entweder ist die Maschine nicht erreichbar, oder ihr Fingerabdruck weicht von dem hinterlegten ab. Was davon, steht im Protokoll des Terminal-Dienstes — nicht in diesem Fenster.',
],
'retry' => 'Neu verbinden',
],
// Gründe, die schon der Server kennt. Sie stehen sofort da, statt dass
// ein Fenster aufgeht und auf eine Verbindung wartet, die es nicht
// geben kann.
'problem' => [
'no_key' => [
'title' => 'Kein SSH-Schlüssel hinterlegt',
'note' => 'Ohne den Flottenschlüssel kann sich niemand an einem Host anmelden. Er wird in den Zugangsdaten hinterlegt.',
],
'not_ready' => [
'title' => 'Dieser Host ist noch nicht so weit',
'note' => 'Es fehlt die Tunneladresse oder der geprüfte Fingerabdruck. Beides entsteht während der Übernahme — solange die nicht durch ist, kann keine sichere Sitzung entstehen.',
],
'unknown' => [
'title' => 'Das Terminal lässt sich gerade nicht öffnen',
'note' => 'Beim Ausstellen des Tickets ist etwas schiefgegangen. Der Vorfall steht im Protokoll; ein neuer Anlauf ist einen Versuch wert.',
],
],
],
'remove' => 'Host entfernen',

View File

@ -136,8 +136,52 @@ return [
'terminal' => [
'open' => 'Terminal',
'connecting' => 'Connecting to :host …',
'hint' => 'Opens a separate window with a root session on this host.',
// The stage — the panel that sits on top of the terminal. Same panel
// while connecting, when the session ends and when it breaks; only the
// words change. The lower four are only ever needed by the browser and
// travel to it as JSON.
'stage' => [
'connecting' => [
'title' => 'Connecting to :host',
'note' => 'Redeeming the one-time ticket and opening the session.',
],
'ended' => [
'title' => 'Session ended',
'note' => 'The connection to :host was closed. Another attempt opens a fresh session.',
],
'lost' => [
'title' => 'No connection',
'note' => 'No session came up on :host. Is the terminal service running, and is the tunnel up?',
],
'rejected' => [
'title' => 'Ticket no longer valid',
'note' => 'A ticket lasts thirty seconds and works exactly once. This one had expired or was already redeemed — another attempt issues a fresh one.',
],
'unreachable' => [
'title' => ':host is not answering',
'note' => 'Signing in through the tunnel failed: either the machine is unreachable, or its fingerprint differs from the one on file. Which of the two is in the terminal service log, not in this window.',
],
'retry' => 'Reconnect',
],
// Reasons the server already knows. They appear straight away instead
// of a window that opens and waits for a connection that cannot happen.
'problem' => [
'no_key' => [
'title' => 'No SSH key on file',
'note' => 'Without the fleet key nobody can sign in to a host. It is stored under credentials.',
],
'not_ready' => [
'title' => 'This host is not ready yet',
'note' => 'The tunnel address or the verified fingerprint is missing. Both appear during takeover — until that is through, no secure session can exist.',
],
'unknown' => [
'title' => 'The terminal cannot be opened right now',
'note' => 'Something went wrong issuing the ticket. The incident is in the log; another attempt is worth a try.',
],
],
],
'remove' => 'Remove host',

View File

@ -13,13 +13,41 @@ import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
const root = document.querySelector('[data-terminal]')
if (root) {
const splash = root.querySelector('[data-terminal-splash]')
const stage = root?.querySelector('[data-terminal-stage]')
// Der Knopf gehört zur Bühne, nicht zur Sitzung, und wird deshalb VOR der
// Weiche unten verdrahtet: bei einem Grund, den schon der Server kennt, gibt es
// gar kein Ticket — und der Knopf steht trotzdem da und muss tun.
//
// Neu laden statt neu verbinden, weil ein Ticket dreißig Sekunden gilt und genau
// einmal: derselbe Wert ein zweites Mal wäre kein zweiter Versuch.
stage?.querySelector('[data-stage-retry-button]')?.addEventListener('click', () => location.reload())
// Ohne Ticket gibt es nichts zu verbinden: die Seite hat dann schon
// hingeschrieben, woran es liegt (HostTerminal::mount, `$problem`), und ein
// Socket, den wir trotzdem aufmachten, überschriebe diesen Satz mit einem
// zweiten, ungenaueren.
if (root && root.dataset.ticket) {
const screen = root.querySelector('[data-terminal-screen]')
// Der Name allein — nie die Tunneladresse oder der Schlüssel, die sieht
// dieses Fenster ohnehin nie. Nur in den Meldungen unten, damit klar ist,
// WELCHER Host gemeint ist, falls je mehr als ein Terminalfenster offen steht.
const host = root.dataset.host
const stageTitle = stage.querySelector('[data-stage-title]')
const stageNote = stage.querySelector('[data-stage-note]')
const stageRetry = stage.querySelector('[data-stage-retry]')
// Der Hostname steht nirgends im Skript: er ist schon in die Meldungen
// eingesetzt, die der Server mitgegeben hat. Tunneladresse und Schlüssel
// sieht dieses Fenster ohnehin nie.
const messages = JSON.parse(stage.dataset.stageMessages)
/**
* Die Bühne zeigen. `which` ist einer der Schlüssel aus
* `data-stage-messages`; `offerRetry` entscheidet über den Knopf er kommt
* nur, wo ein zweiter Anlauf überhaupt etwas ändern kann.
*/
const showStage = (which, offerRetry) => {
stageTitle.textContent = messages[which].title
stageNote.textContent = messages[which].note
stageRetry.classList.toggle('hidden', !offerRetry)
stage.classList.remove('hidden')
}
const term = new Terminal({ convertEol: true, fontFamily: 'ui-monospace, monospace', fontSize: 13 })
const fit = new FitAddon()
@ -38,26 +66,24 @@ if (root) {
const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws`, [root.dataset.ticket])
socket.binaryType = 'arraybuffer'
// Der Vorspann weicht beim ERSTEN BYTE, nicht bei `onopen`.
let opened = false
// xterm hängt sofort im Schirm, nicht erst beim ersten Byte: das Element hat
// von Anfang an seine volle Größe (die Bühne liegt darüber, nicht daneben),
// und `fit()` rechnet nur an einem Element richtig, das schon Maße hat.
term.open(screen)
fit.fit()
// Die Bühne weicht beim ERSTEN BYTE, nicht bei `onopen`: ein stehender
// Socket sagt noch nichts darüber, ob am anderen Ende eine Sitzung entstanden
// ist, und der Unterschied fiele sonst erst auf, wenn jemand ins Leere tippt.
let received = false
const reveal = () => {
if (opened) return
opened = true
splash.classList.add('hidden')
screen.classList.remove('hidden')
term.open(screen)
if (received) return
received = true
stage.classList.add('hidden')
fit.fit()
}
// Ob je ein Byte ankam — getrennt von `opened`, denn auch ein Verbindungsfehler
// lässt den Vorspann weichen, um die Fehlermeldung zu zeigen, ohne dass je eine
// Sitzung entstand. Ohne dieses Merkmal feuert ein fehlgeschlagener Socket
// `error` UND danach `close`, und `onclose` behauptete eine beendete Sitzung,
// die nie begonnen hat.
let received = false
socket.onmessage = (event) => {
received = true
reveal()
// Kommt vom Container ein Textrahmen statt eines Binärrahmens, ist
// `new Uint8Array(event.data)` bei einem String ein leeres Array —
@ -72,15 +98,27 @@ if (root) {
term.onData((data) => socket.readyState === WebSocket.OPEN && socket.send(data))
socket.onclose = () => {
if (!received) return
term.write(`\r\n\x1b[31m— Verbindung zu ${host} beendet —\x1b[0m\r\n`)
// Die Schließcodes kommen aus docker/terminal/bridge.py und sind die einzige
// Auskunft, die die Brücke über den Grund gibt — absichtlich: was genau am
// Host schiefging, steht in ihrem Log, nicht in diesem Fenster.
//
// 4401 Ticket unbekannt, abgelaufen oder schon eingelöst
// 4502 kein SSH zum Host (keine Antwort, oder Fingerabdruck weicht ab)
//
// Alles andere ist ein gewöhnliches Ende: die Gegenseite hat aufgelegt.
// Das kann die Sitzung selbst sein (`exit`), der Leerlaufwächter der Brücke
// oder eine Leitung, die abgerissen ist — für den Betreiber ist der
// Unterschied derselbe: hier läuft nichts mehr, und ein neuer Anlauf hilft.
socket.onclose = (event) => {
if (event.code === 4401) return showStage('rejected', true)
if (event.code === 4502) return showStage('unreachable', true)
showStage(received ? 'ended' : 'lost', true)
}
socket.onerror = () => {
reveal()
term.write(`\r\n\x1b[31m— Verbindung zu ${host} nicht möglich —\x1b[0m\r\n`)
}
// Kein `onerror`: ein Fehler zieht immer ein `close` nach sich, und dort oben
// steht die Meldung — mit einem Code, der etwas aussagt. Zwei Handler
// schrieben zwei Sätze über denselben Vorgang, und genau das stand vorher da.
addEventListener('resize', () => opened && fit.fit())
addEventListener('resize', () => fit.fit())
}

View File

@ -29,22 +29,22 @@
{{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }}
</x-ui.button>
@endif
{{-- 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 immer da, auch wenn dem Host noch die Tunneladresse
oder der Fingerabdruck fehlt. Die Seite dahinter geht nicht mehr mit
einer Laravel-Fehlerseite auf, sondern schreibt hin, was fehlt
(`HostTerminal::mount()`, `hosts.terminal.problem.*`) und damit ist
ein fehlender Knopf die schlechtere Auskunft von beiden. Ausführlich
steht das in hosts.blade.php an derselben Stelle. --}}
@can('hosts.manage')
@if ($host->wg_ip && $host->ssh_host_key)
{{-- href direkt am Knopf, nicht als <a> 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). --}}
<x-ui.button variant="secondary" size="sm"
:href="route('admin.hosts.terminal', ['host' => $host->uuid])"
target="_blank" rel="noopener" :title="__('hosts.terminal.hint')">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
@endif
{{-- href direkt am Knopf, nicht als <a> 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). --}}
<x-ui.button variant="secondary" size="sm"
:href="route('admin.hosts.terminal', ['host' => $host->uuid])"
target="_blank" rel="noopener" :title="__('hosts.terminal.hint')">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
@endcan
{{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft
dieselbe, und zwei Fassungen liefen auseinander. --}}

View File

@ -1,28 +1,116 @@
{{-- Der Vorspann steht, bis wirklich Daten fließen nicht, bis der Socket
offen ist. Ein Socket, der steht, sagt noch nichts darüber, ob am anderen
Ende eine Sitzung entstanden ist; der Unterschied fällt sonst erst auf,
wenn jemand ins Leere tippt. --}}
<div class="flex h-screen w-screen flex-col bg-ink"
data-terminal
data-ticket="{{ $ticket }}"
data-host="{{ $host->name }}">
{{-- Die Bühne liegt ÜBER dem Terminal, nicht daneben.
<div data-terminal-splash class="flex flex-1 items-center justify-center">
{{-- 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. --}}
<pre class="font-mono text-[11px] leading-[1.15] text-accent select-none" role="img" aria-label="CluPilot">
___ _ ___ _ _ _
/ __| |_ _| _ \(_) |___| |_
| (__| | || | _/| | / _ \ _|
\___|_|\_,_|_| |_|_\___/\__|
</pre>
<p class="sr-only">{{ __('hosts.terminal.connecting', ['host' => $host->name]) }}</p>
</div>
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
<div class="relative flex h-screen w-screen flex-col overflow-hidden bg-ink"
data-terminal
data-ticket="{{ $ticket }}">
{{-- 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. --}}
<div data-terminal-screen wire:ignore class="hidden flex-1"></div>
<div data-terminal-screen wire:ignore class="flex-1 overflow-hidden"></div>
<div data-terminal-stage
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-6 bg-ink px-6 text-center"
data-stage-messages="{{ json_encode($messages, JSON_UNESCAPED_UNICODE) }}">
{{-- 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. --}}
<pre data-stage-mark
class="select-none font-mono text-[8px] leading-none text-accent sm:text-[11px]"
role="img" aria-label="CluPilot"> ██████ ██ ██ ██ ██████ ██ ██ ██████ ████████
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██ ██ ██ ██ ██████ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██████ ███████ ██████ ██ ██ ███████ ██████ ██</pre>
{{-- 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. --}}
<div class="max-w-sm space-y-2" aria-live="polite">
<p data-stage-title class="text-sm font-semibold text-bg">{{ $stageTitle }}</p>
<p data-stage-note class="text-xs leading-relaxed text-bg opacity-70">{{ $stageNote }}</p>
</div>
{{-- 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. --}}
<div data-stage-retry @class(['hidden' => $problem !== 'unknown'])>
<x-ui.button data-stage-retry-button variant="secondary" size="sm">
{{ __('hosts.terminal.stage.retry') }}
</x-ui.button>
</div>
</div>
</div>

View File

@ -150,22 +150,27 @@
</td>
<td class="px-4 py-3"><x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge></td>
<td class="px-4 py-3 text-right">
{{-- 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 <a> 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). --}}
<x-ui.button variant="secondary" size="sm"
:href="route('admin.hosts.terminal', ['host' => $host->uuid])"
target="_blank" rel="noopener" :title="__('hosts.terminal.hint')">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
@endif
{{-- href direkt am Knopf, nicht als <a> 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). --}}
<x-ui.button variant="secondary" size="sm"
:href="route('admin.hosts.terminal', ['host' => $host->uuid])"
target="_blank" rel="noopener" :title="__('hosts.terminal.hint')">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
@endcan
</td>
</tr>

View File

@ -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 &quot;.
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 `&quot;`, 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'));
});