125 lines
6.0 KiB
JavaScript
125 lines
6.0 KiB
JavaScript
/*
|
|
* Das Terminal im eigenen Fenster.
|
|
*
|
|
* Eigener Einstiegspunkt, nicht Teil von app.js: nicht weil diese Seite ohne
|
|
* Livewire liefe — sie ist eine Vollseiten-Livewire-Komponente und lädt
|
|
* `<x-shell.head>`, das app.js über bare.blade.php ohnehin mitzieht —,
|
|
* sondern damit der Terminalcode (xterm.js) nicht in app.js landet und die
|
|
* übrigen Konsolenseiten nicht mitschleppen, und umgekehrt app.js nicht in
|
|
* dieses schlanke Fenster.
|
|
*/
|
|
import { Terminal } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import '@xterm/xterm/css/xterm.css'
|
|
|
|
const root = document.querySelector('[data-terminal]')
|
|
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]')
|
|
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()
|
|
term.loadAddon(fit)
|
|
|
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
|
|
// Das Ticket reist als Unterprotokoll, NICHT in der Adresszeile: die Adresse
|
|
// eines Upgrade-Antrags schreibt jeder Reverse Proxy auf der Strecke mit
|
|
// (nginx hier, Caddy davor), ein Kopffeld nicht. Die Brücke spiegelt den
|
|
// Wert zurück, sonst bricht der Browser den Handschlag ab.
|
|
//
|
|
// `/terminal/ws` steht fest an der Wurzel und folgt AdminArea::prefix()
|
|
// bewusst nicht: die Stelle wird von nginx durchgereicht und sieht PHP nie,
|
|
// also muss sie ein fester Text sein — in docker/nginx/default.conf steht
|
|
// derselbe, mit derselben Begründung.
|
|
const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws`, [root.dataset.ticket])
|
|
socket.binaryType = 'arraybuffer'
|
|
|
|
// 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 (received) return
|
|
received = true
|
|
stage.classList.add('hidden')
|
|
fit.fit()
|
|
}
|
|
|
|
socket.onmessage = (event) => {
|
|
reveal()
|
|
// Kommt vom Container ein Textrahmen statt eines Binärrahmens, ist
|
|
// `new Uint8Array(event.data)` bei einem String ein leeres Array —
|
|
// `binaryType = 'arraybuffer'` oben regelt nur Binärrahmen.
|
|
//
|
|
// Die Brücke hat sich inzwischen festgelegt: sie sendet ausschließlich
|
|
// Binärrahmen (docker/terminal/bridge.py, to_browser). Diese Verzweigung
|
|
// bleibt trotzdem stehen — sie kostet nichts und ist die einzige Stelle,
|
|
// an der ein Rückfall nicht als stumme leere Ausgabe ankäme.
|
|
term.write(typeof event.data === 'string' ? event.data : new Uint8Array(event.data))
|
|
}
|
|
|
|
term.onData((data) => socket.readyState === WebSocket.OPEN && socket.send(data))
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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', () => fit.fit())
|
|
}
|