CluPilotCloud/resources/js/terminal.js

150 lines
7.4 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)
if (received) return showStage('ended', true)
// Nie eine Sitzung gehabt — und genau hier wusste das Fenster bisher
// nichts und riet.
//
// Scheitert ein WebSocket schon am Handschlag, bekommt die Seite laut
// Norm KEINEN HTTP-Status: `event.code` ist 1006, sonst nichts. Das ist
// Absicht (sonst wäre ein Socket ein Portscanner), macht aber genau die
// Auskunft unmöglich, die ein Betreiber hier braucht — läuft die Brücke
// nicht, oder ist die Leitung weg? Beides sah gleich aus.
//
// Eine gewöhnliche Anfrage an dieselbe Stelle darf den Status sehen.
// nginx antwortet dort mit 502, solange die Brücke nicht erreichbar ist
// (`resolver` + Variable, docker/nginx/default.conf), mit 404 auf einem
// öffentlichen Namen, und sonst mit dem, was die Brücke selbst sagt —
// ein Upgrade-Fehler, aber eben von ihr. Das reicht zum Unterscheiden.
showStage('lost', true)
fetch('/terminal/ws', { cache: 'no-store' })
.then((probe) => {
if ([502, 503, 504].includes(probe.status)) showStage('service_down', true)
else if (probe.status === 404) showStage('not_here', false)
})
.catch(() => {
// Die Anfrage kam nicht einmal los: dann ist wirklich die
// Leitung weg, und „Keine Verbindung" von oben stimmt.
})
}
// 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())
}