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
parent
c6403bf829
commit
f64a564c40
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. --}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue