diff --git a/app/Livewire/Admin/HostTerminal.php b/app/Livewire/Admin/HostTerminal.php
new file mode 100644
index 0000000..2df6530
--- /dev/null
+++ b/app/Livewire/Admin/HostTerminal.php
@@ -0,0 +1,67 @@
+ …])` — bleibt
+ * unverändert, das betrifft nur, wie diese Klasse den Wert intern hält).
+ */
+#[Layout('layouts.bare')]
+class HostTerminal extends Component
+{
+ public Host $record;
+
+ public string $ticket = '';
+
+ public function mount(string $host): void
+ {
+ $this->authorize('hosts.manage');
+
+ $this->record = Host::query()->where('uuid', $host)->firstOrFail();
+
+ /** @var Operator $operator */
+ $operator = auth('operator')->user();
+
+ $this->ticket = TerminalTicket::issue($this->record, $operator);
+ }
+
+ public function render()
+ {
+ // 'host' ist der Name, den die Ansicht erwartet — die Eigenschaft
+ // heißt aus dem oben erklärten Grund anders.
+ return view('livewire.admin.host-terminal', ['host' => $this->record])
+ ->title($this->record->name.' — Terminal');
+ }
+}
diff --git a/lang/de/hosts.php b/lang/de/hosts.php
index 63d494e..73a148b 100644
--- a/lang/de/hosts.php
+++ b/lang/de/hosts.php
@@ -134,6 +134,12 @@ return [
'retry' => 'Erneut versuchen',
'error_title' => 'Onboarding fehlgeschlagen',
+ 'terminal' => [
+ 'open' => 'Terminal',
+ 'connecting' => 'Verbinde mit :host …',
+ 'hint' => 'Öffnet ein eigenes Fenster mit einer Root-Sitzung auf diesem Host.',
+ ],
+
'remove' => 'Host entfernen',
'remove_title' => 'Host entfernen?',
'remove_body' => 'Entfernt nur den CluPilot-Eintrag von :name. Der physische Server wird NICHT gelöscht oder zurückgesetzt.',
diff --git a/lang/en/hosts.php b/lang/en/hosts.php
index dd2c82d..e2e5d0f 100644
--- a/lang/en/hosts.php
+++ b/lang/en/hosts.php
@@ -134,6 +134,12 @@ return [
'retry' => 'Retry',
'error_title' => 'Onboarding failed',
+ 'terminal' => [
+ 'open' => 'Terminal',
+ 'connecting' => 'Connecting to :host …',
+ 'hint' => 'Opens a separate window with a root session on this host.',
+ ],
+
'remove' => 'Remove host',
'remove_title' => 'Remove host?',
'remove_body' => 'Removes only the CluPilot record for :name. The physical server is NOT deleted or wiped.',
diff --git a/package-lock.json b/package-lock.json
index ab416a7..d1a46f6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@fontsource/ibm-plex-serif": "^5.3.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
"alpinejs": "^3.15.12",
"chart.js": "^4.5.1",
"laravel-echo": "^2.4.0",
@@ -498,6 +500,21 @@
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"license": "MIT"
},
+ "node_modules/@xterm/addon-fit": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
+ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
+ "license": "MIT"
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
+ "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
+ "license": "MIT",
+ "workspaces": [
+ "addons/*"
+ ]
+ },
"node_modules/alpinejs": {
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
diff --git a/package.json b/package.json
index 74e728a..ab55197 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,8 @@
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@fontsource/ibm-plex-serif": "^5.3.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
"alpinejs": "^3.15.12",
"chart.js": "^4.5.1",
"laravel-echo": "^2.4.0",
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
new file mode 100644
index 0000000..a08602d
--- /dev/null
+++ b/resources/js/terminal.js
@@ -0,0 +1,54 @@
+/*
+ * Das Terminal im eigenen Fenster.
+ *
+ * Eigener Einstiegspunkt, nicht Teil von app.js: diese Seite lädt weder
+ * Livewire noch Chart.js, und app.js zöge beides mit — auf einer Seite, die
+ * eine WebSocket-Verbindung und ein Terminal ist, sonst nichts.
+ */
+import { Terminal } from '@xterm/xterm'
+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 screen = root.querySelector('[data-terminal-screen]')
+
+ 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'
+ const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws?t=${encodeURIComponent(root.dataset.ticket)}`)
+ socket.binaryType = 'arraybuffer'
+
+ // Der Vorspann weicht beim ERSTEN BYTE, nicht bei `onopen`.
+ let opened = false
+ const reveal = () => {
+ if (opened) return
+ opened = true
+ splash.classList.add('hidden')
+ screen.classList.remove('hidden')
+ term.open(screen)
+ fit.fit()
+ }
+
+ socket.onmessage = (event) => {
+ reveal()
+ term.write(new Uint8Array(event.data))
+ }
+
+ term.onData((data) => socket.readyState === WebSocket.OPEN && socket.send(data))
+
+ socket.onclose = () => {
+ reveal()
+ term.write('\r\n\x1b[31m— Verbindung beendet —\x1b[0m\r\n')
+ }
+
+ socket.onerror = () => {
+ reveal()
+ term.write('\r\n\x1b[31m— Verbindung nicht möglich —\x1b[0m\r\n')
+ }
+
+ addEventListener('resize', () => opened && fit.fit())
+}
diff --git a/resources/views/layouts/bare.blade.php b/resources/views/layouts/bare.blade.php
new file mode 100644
index 0000000..54e7a58
--- /dev/null
+++ b/resources/views/layouts/bare.blade.php
@@ -0,0 +1,18 @@
+
+
+
+
+ {{-- Eigener Einstiegspunkt, nicht app.js: siehe resources/js/terminal.js. --}}
+ @vite('resources/js/terminal.js')
+
+{{--
+ Das schlichte Layout — für Seiten, die kein Konsolen-Layout wollen.
+
+ Kein Kopf, keine Seitenleiste, keine Statusleiste: das Terminal bekommt die
+ volle Fläche eines eigenen Fensters. `layouts/admin.blade.php` bringt all
+ das mit; hier fehlt es mit Absicht.
+--}}
+
+ {{ $slot }}
+
+
diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php
index cfa76b1..4ee74a5 100644
--- a/resources/views/livewire/admin/host-detail.blade.php
+++ b/resources/views/livewire/admin/host-detail.blade.php
@@ -29,6 +29,21 @@
{{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }}
@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. --}}
+ @can('hosts.manage')
+ @if ($host->wg_ip && $host->ssh_host_key)
+
+
+ {{ __('hosts.terminal.open') }}
+
+
+ @endif
+ @endcan
{{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft
dieselbe, und zwei Fassungen liefen auseinander. --}}
@if (\App\Livewire\Admin\ReissueTakeover::eligible($host))
diff --git a/resources/views/livewire/admin/host-terminal.blade.php b/resources/views/livewire/admin/host-terminal.blade.php
new file mode 100644
index 0000000..32f70e0
--- /dev/null
+++ b/resources/views/livewire/admin/host-terminal.blade.php
@@ -0,0 +1,24 @@
+{{-- 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. --}}
+
+
+
+ {{-- 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]) }}
+
+
+
+
diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php
index 3767ed2..0ded973 100644
--- a/resources/views/livewire/admin/hosts.blade.php
+++ b/resources/views/livewire/admin/hosts.blade.php
@@ -81,6 +81,12 @@
{{ __('hosts.col.instances') }} |
{{ __('hosts.col.capacity') }} |
{{ __('hosts.col.status') }} |
+ {{-- Leer mit Absicht: die Spalte trägt nur den
+ Terminal-Knopf, und der ist nicht auf jeder
+ Zeile da (Berechtigung, Betriebsbereitschaft).
+ Eine Beschriftung, die unabhängig davon immer
+ dastünde, wäre eine falsche Zusage. --}}
+ |
@@ -143,6 +149,23 @@
@endif
{{ __('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. --}}
+ @can('hosts.manage')
+ @if ($host->wg_ip && $host->ssh_host_key)
+
+
+ {{ __('hosts.terminal.open') }}
+
+
+ @endif
+ @endcan
+ |
@endforeach
diff --git a/routes/admin.php b/routes/admin.php
index 76289f8..c8cbb56 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -35,6 +35,10 @@ Route::get('/instances', Admin\Instances::class)->name('instances');
Route::get('/hosts', Admin\Hosts::class)->name('hosts');
Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create');
Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show');
+// Das Terminal öffnet sich in einem eigenen Fenster und hat deshalb keinen
+// Navigationseintrag — es wird nur aus der Liste und der Detailseite heraus
+// aufgerufen. `hosts.manage` prüft das Bauteil selbst in mount().
+Route::get('/hosts/{host}/terminal', Admin\HostTerminal::class)->name('hosts.terminal');
Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters');
// Hostnamen und Zertifikate. Eigene Seite und nicht unter Einstellungen: was
diff --git a/tests/Feature/Admin/HostTerminalTest.php b/tests/Feature/Admin/HostTerminalTest.php
index 9194ebe..6c919fa 100644
--- a/tests/Feature/Admin/HostTerminalTest.php
+++ b/tests/Feature/Admin/HostTerminalTest.php
@@ -135,3 +135,56 @@ it('gibt kein Ticket ohne geprüften Fingerabdruck aus', function () {
expect(fn () => TerminalTicket::issue($host, Operator::factory()->role('Owner')->create()))
->toThrow(RuntimeException::class, "Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket.");
});
+
+// ── Aufgabe 2: die Seite und die Knöpfe ─────────────────────────────────────
+
+it('lässt niemanden ohne hosts.manage an ein Terminal', function () {
+ // Mit Fingerabdruck: sonst schlüge der Aufruf schon an issue() selbst
+ // fehl (siehe Test oben), nicht an der Berechtigungsprüfung — und dieser
+ // Test soll genau LETZTERE belegen.
+ $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
+
+ // „Read-only" darf die Konsole betreten und sonst nichts.
+ $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator')
+ ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
+ ->assertForbidden();
+});
+
+it('zeigt den Vorspann und reicht das Ticket weiter, aber niemals den Schlüssel', function () {
+ $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
+
+ $html = $this->actingAs(admin(), 'operator')
+ ->get(route('admin.hosts.terminal', ['host' => $host->uuid]))
+ ->assertOk()
+ ->getContent();
+
+ // Der Vorspann steht da, bevor irgendetwas verbindet.
+ expect($html)->toContain('CluPilot')
+ // Und nichts, was die Brücke geheim halten muss.
+ ->and($html)->not->toContain('BEGIN OPENSSH PRIVATE KEY')
+ ->and($html)->not->toContain($host->wg_ip);
+});
+
+it('bietet den Terminal-Knopf nur dem, der ihn drücken darf', function () {
+ Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
+
+ $erlaubt = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent();
+ expect($erlaubt)->toContain(__('hosts.terminal.open'));
+
+ $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator');
+ $verwehrt = $this->get(route('admin.hosts'))->getContent();
+ expect($verwehrt)->not->toContain(__('hosts.terminal.open'));
+});
+
+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();
+
+ $html = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent();
+
+ expect($html)->not->toContain(__('hosts.terminal.open'));
+});
diff --git a/vite.config.js b/vite.config.js
index 8ecb276..1066bb1 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -17,7 +17,11 @@ export default defineConfig({
// site.js ist ein eigener Einstiegspunkt, kein Teil von app.js: es
// startet Alpine, und auf Konsolen- und Portalseiten tut das schon
// Livewire. Zwei Starts zerlegen beide. Siehe resources/js/site.js.
- input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/site.js'],
+ //
+ // terminal.js ist aus demselben Grund ein eigener Einstiegspunkt:
+ // das Terminalfenster hat weder Livewire noch Chart.js nötig, die
+ // app.js sonst mitzöge. Siehe resources/js/terminal.js.
+ input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/site.js', 'resources/js/terminal.js'],
refresh: true,
}),
],