diff --git a/app/Livewire/Admin/Hosts.php b/app/Livewire/Admin/Hosts.php index bf67494..d8e602c 100644 --- a/app/Livewire/Admin/Hosts.php +++ b/app/Livewire/Admin/Hosts.php @@ -53,6 +53,10 @@ class Hosts extends Component 'datacenters' => Datacenter::query()->orderBy('name')->get(), 'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'], 'total' => Host::query()->count(), + // Für die aufklappbare Anleitung: dieselbe Adresse, die später in + // der Befehlszeile steht, damit hier nichts anderes behauptet wird + // als dort getan wird. + 'archiveUrl' => \App\Support\HostTakeoverCommand::archiveUrl(), ]); } } diff --git a/lang/de/hosts.php b/lang/de/hosts.php index f1fdf27..51d1be5 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -135,28 +135,41 @@ return [ 'complete_host_onboarding' => 'Onboarding abschließen', ], 'takeover' => [ - 'before_title' => 'Vorher: Rettungssystem starten', - 'before_body' => 'Bestelle den Server beim Anbieter und starte sein Rettungssystem, bevor du hier anlegst. Direkt nach dem Anlegen zeigt diese Seite eine Befehlszeile, die im Rettungssystem eingefügt wird — sie wird genau einmal gezeigt.', + 'how_title' => 'So läuft eine Host-Übernahme ab', + 'how_sub' => 'Sechs Schritte, etwa 20 bis 40 Minuten. Die ersten beiden passieren beim Anbieter — bevor hier etwas angelegt wird.', + + 'badge_before' => 'vorher', + 'badge_now' => 'jetzt', + 'badge_later' => 'danach', + + 's1_title' => 'Server bestellen', + 's1_body' => 'Eine dedizierte Maschine. Keine Cloud-Instanz: ohne Hardware-Virtualisierung (/dev/kvm) startet später kein einziger Gast, und das Skript weist die Maschine gleich zu Beginn ab. Bei Hetzner CPX/CX ist das immer so, bei netcup je nach Produkt.', + + 's2_title' => 'Rettungssystem starten', + 's2_body' => 'Im Kundenbereich des Anbieters das Rettungssystem einschalten UND den Server neu starten. Einschalten allein genügt nicht — er muss wirklich darin hochgefahren sein, sonst weigert sich das Skript. Es überschreibt Platten und prüft deshalb zuerst, ob es das darf.', + + 's3_title' => 'Host hier anlegen', + 's3_body' => 'Name, Rechenzentrum, öffentliche IP und root-Kennwort eintragen und speichern. Erst danach entsteht der Einmal-Code, und ab dann läuft seine Frist von 24 Stunden.', + + 's4_title' => 'Die Befehlszeile kopieren', + 's4_body' => 'Sie erscheint direkt nach dem Speichern und wird genau einmal gezeigt.', + + 's5_title' => 'Im Rettungssystem einfügen', + 's5_body' => 'Per SSH als root auf den Server, Zeile einfügen, Eingabetaste. Es wird nichts abgetippt und nichts ausgefüllt: die Zeile trägt alles, was der Server vor dem Tunnel braucht.', + 's5_hint' => 'Die Zeile holt das Installationsskript von :url, packt es nach /opt/clupilot aus und startet es. Mehr lädt der Server nicht nach.', + + 's6_title' => 'Zusehen', + 's6_body' => 'Der Fortschritt läuft auf der Host-Seite mit, Abschnitt für Abschnitt. Bis der Tunnel steht, meldet der Server nichts — das ist so gewollt und dauert über den ersten Neustart hinweg. Danach kommt alles Vorherige auf einmal nach, mit den Zeiten von damals.', 'missing_title' => 'Der Tunnel ist noch nicht eingerichtet', 'missing_body' => 'Es fehlt: :settings. Ohne diese Werte entsteht eine Befehlszeile, die sauber aussieht, läuft — und in einem Tunnel endet, der nie einen Handshake hat. Das fällt erst auf der Maschine auf. Trage sie unter Einstellungen ein, bevor du einen Host anlegst.', 'title' => ':name ist angelegt', - 'subtitle' => 'Drei Schritte, danach macht der Server den Rest allein.', + 'subtitle' => 'Noch drei Schritte, dann macht der Server den Rest allein.', 'once_title' => 'Diese Zeile gibt es nur jetzt', 'once_body' => 'In der Datenbank steht nur der Hash des Codes, der WireGuard-Schlüssel steht dort gar nicht. Wer die Seite verlässt oder neu lädt, bekommt sie nicht wieder, sondern legt einen neuen Code an — der alte ist damit wertlos.', - 'step1_title' => 'Rettungssystem starten', - 'step1_body' => 'Im Kundenbereich des Anbieters das Rettungssystem einschalten und den Server neu starten. Einschalten allein genügt nicht: er muss wirklich darin hochgefahren sein, sonst weigert sich das Skript — es überschreibt Platten und prüft deshalb zuerst, ob es das darf.', - - 'step2_title' => 'Diese Zeile im Rettungssystem einfügen', - 'step2_body' => 'Per SSH als root auf den Server, Zeile einfügen, Eingabetaste. Es wird nichts abgetippt und nichts ausgefüllt: sie trägt alles, was der Server vor dem Tunnel braucht.', - 'step2_hint' => 'Die Zeile holt das Installationsskript von :url, packt es nach /opt/clupilot aus und startet es. Mehr lädt der Server nicht nach.', - - 'step3_title' => 'Zusehen', - 'step3_body' => 'Der Fortschritt läuft in der Konsole mit, Abschnitt für Abschnitt. Bis der Tunnel steht, meldet der Server nichts — das ist so gewollt und dauert über den ersten Neustart hinweg. Danach kommt alles Vorherige auf einmal nach, mit den Zeiten von damals.', - 'watch' => 'Fortschritt ansehen', 'copy' => 'Kopieren', 'copied' => 'Kopiert', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index 0f4e0b6..099f196 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -135,28 +135,41 @@ return [ 'complete_host_onboarding' => 'Complete onboarding', ], 'takeover' => [ - 'before_title' => 'First: boot the rescue system', - 'before_body' => 'Order the server at the provider and boot its rescue system before you add it here. Right after you add it, this page shows a command line to paste into the rescue system — shown exactly once.', + 'how_title' => 'How a host takeover works', + 'how_sub' => 'Six steps, roughly 20 to 40 minutes. The first two happen at the provider — before anything is created here.', + + 'badge_before' => 'first', + 'badge_now' => 'now', + 'badge_later' => 'then', + + 's1_title' => 'Order the server', + 's1_body' => 'A dedicated machine. Not a cloud instance: without hardware virtualisation (/dev/kvm) no guest will ever start, and the script turns the machine away at the very beginning. Hetzner CPX/CX never has it; netcup depends on the product.', + + 's2_title' => 'Boot the rescue system', + 's2_body' => 'Enable the rescue system in the provider\'s panel AND restart the server. Enabling alone is not enough — it has to actually be running in it, or the script refuses. It overwrites disks, so it checks first whether it may.', + + 's3_title' => 'Add the host here', + 's3_body' => 'Name, datacenter, public IP and root password, then save. The one-time code is created at that point, and its 24-hour window starts running.', + + 's4_title' => 'Copy the command line', + 's4_body' => 'It appears right after saving and is shown exactly once.', + + 's5_title' => 'Paste it into the rescue system', + 's5_body' => 'SSH in as root, paste the line, press enter. Nothing is typed out and nothing is filled in: the line carries everything the server needs before the tunnel exists.', + 's5_hint' => 'The line fetches the installer from :url, unpacks it to /opt/clupilot and starts it. The server downloads nothing else.', + + 's6_title' => 'Watch', + 's6_body' => 'Progress appears on the host page, section by section. Until the tunnel is up the server reports nothing — that is intended, and it lasts across the first reboot. Everything before it then arrives at once, carrying the times it actually happened.', 'missing_title' => 'The tunnel is not set up yet', 'missing_body' => 'Missing: :settings. Without these, the command line looks clean, runs, and ends in a tunnel that never handshakes — which only shows up on the machine. Fill them in under Settings before adding a host.', 'title' => ':name is created', - 'subtitle' => 'Three steps, then the server does the rest on its own.', + 'subtitle' => 'Three steps left, then the server does the rest on its own.', 'once_title' => 'This line exists only now', 'once_body' => 'The database holds only the hash of the code, and not the WireGuard key at all. Leaving or reloading this page does not bring it back — it mints a new code, and the old one becomes worthless.', - 'step1_title' => 'Boot the rescue system', - 'step1_body' => 'Enable the rescue system in the provider\'s panel and restart the server. Enabling alone is not enough: it has to actually be running in it, or the script refuses — it overwrites disks, so it checks first whether it may.', - - 'step2_title' => 'Paste this line into the rescue system', - 'step2_body' => 'SSH in as root, paste the line, press enter. Nothing is typed out and nothing is filled in: it carries everything the server needs before the tunnel exists.', - 'step2_hint' => 'The line fetches the installer from :url, unpacks it to /opt/clupilot and starts it. The server downloads nothing else.', - - 'step3_title' => 'Watch', - 'step3_body' => 'Progress appears in the console, section by section. Until the tunnel is up the server reports nothing — that is intended, and it lasts across the first reboot. Everything before it then arrives at once, carrying the times it actually happened.', - 'watch' => 'Watch progress', 'copy' => 'Copy', 'copied' => 'Copied', diff --git a/resources/views/components/admin/takeover-guide.blade.php b/resources/views/components/admin/takeover-guide.blade.php new file mode 100644 index 0000000..331a165 --- /dev/null +++ b/resources/views/components/admin/takeover-guide.blade.php @@ -0,0 +1,109 @@ +@props([ + // Die fertige Befehlszeile, sobald der Host angelegt ist — sonst null. + 'command' => null, + 'archiveUrl' => '', + 'hostUuid' => null, +]) + +{{-- Die Anleitung für eine Host-Übernahme, an EINER Stelle geschrieben. + + Sie steht bewusst dort, wo jemand sie braucht, bevor er anfängt: die + Schritte 1 und 2 passieren beim Anbieter, und wer sie erst nach dem Anlegen + liest, hat den Einmal-Code schon in der Zwischenablage und dessen Frist + schon laufen. Die erste Fassung dieser Seite zeigte die Anleitung erst + hinterher — das war genau dieser Fehler. + + Deshalb sind IMMER alle sechs Schritte zu sehen, auch die erledigten und + die kommenden. Eine Anleitung, die nur den aktuellen Schritt zeigt, + beantwortet die Frage „wie lange dauert das noch" nicht. --}} + +@php + $done = $command !== null; + // Schritt 3 ist das Formular auf dieser Seite; ist die Zeile da, sind 1–3 + // vorbei und 4 ist dran. + $current = $done ? 4 : 3; + + $steps = [ + 1 => ['title' => __('hosts.takeover.s1_title'), 'body' => __('hosts.takeover.s1_body')], + 2 => ['title' => __('hosts.takeover.s2_title'), 'body' => __('hosts.takeover.s2_body')], + 3 => ['title' => __('hosts.takeover.s3_title'), 'body' => __('hosts.takeover.s3_body')], + 4 => ['title' => __('hosts.takeover.s4_title'), 'body' => __('hosts.takeover.s4_body')], + 5 => ['title' => __('hosts.takeover.s5_title'), 'body' => __('hosts.takeover.s5_body')], + 6 => ['title' => __('hosts.takeover.s6_title'), 'body' => __('hosts.takeover.s6_body')], + ]; +@endphp + +
merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}> +
+

{{ __('hosts.takeover.how_title') }}

+

{{ __('hosts.takeover.how_sub') }}

+
+ +
    + @foreach ($steps as $number => $step) + @php + $isCurrent = $number === $current; + $isPast = $number < $current; + @endphp +
  1. + {{-- Die Nummer trägt den Zustand, nicht ein Icon daneben: eine + Ziffer, die man mit dem Blick abzählen kann, ist an dieser + Stelle mehr wert als ein Häkchen. --}} + + {{ $number }} + + +
    +
    +

    {{ $step['title'] }}

    + @if ($number <= 2 && ! $done) + {{ __('hosts.takeover.badge_before') }} + @elseif ($isCurrent) + {{ __('hosts.takeover.badge_now') }} + @endif +
    +

    {{ $step['body'] }}

    + + @if ($number === 4 && $done) +
    +
    + {{-- Umbrechen statt waagerecht rollen: aus einem + Kasten mit Rollbalken markiert jemand die + Hälfte und merkt es erst auf der Maschine. --}} +
    {{ $command }}
    + +
    +
    + @endif + + @if ($number === 5) +

    {{ __('hosts.takeover.s5_hint', ['url' => $archiveUrl]) }}

    + @endif + + @if ($number === 6 && $hostUuid) + + @endif +
    +
  2. + @endforeach +
+ +
+

{{ __('hosts.takeover.footnote') }}

+
+
diff --git a/resources/views/livewire/admin/host-create.blade.php b/resources/views/livewire/admin/host-create.blade.php index 6566f9b..996eb3f 100644 --- a/resources/views/livewire/admin/host-create.blade.php +++ b/resources/views/livewire/admin/host-create.blade.php @@ -8,15 +8,6 @@

{{ __('hosts.create_sub') }}

- {{-- Was der Betreiber VORHER wissen muss, steht vor dem Formular und - nicht danach: das Rettungssystem zu starten dauert beim Anbieter ein - paar Minuten. Wer das erst hinterher liest, hat den Code schon in - der Zwischenablage und wartet. --}} - -

{{ __('hosts.takeover.before_title') }}

-

{{ __('hosts.takeover.before_body') }}

-
- @if ($missingSettings)

{{ __('hosts.takeover.missing_title') }}

@@ -24,6 +15,12 @@
@endif + {{-- Die Anleitung steht VOR dem Formular, weil ihre ersten beiden + Schritte beim Anbieter passieren und vor dem Anlegen erledigt sein + müssen. Wer sie erst danach liest, hat den Einmal-Code schon und + dessen Frist läuft. --}} + +
@@ -58,9 +55,8 @@ @else - {{-- Der Host steht. Ab hier ist diese Seite eine Anleitung und kein - Formular mehr — und sie ist die EINZIGE Stelle, an der die - Befehlszeile je zu sehen ist. --}} + {{-- Der Host steht. Dieselbe Anleitung, jetzt mit der Befehlszeile an + Schritt 4 — und ohne Formular, weil es nichts mehr auszufüllen gibt. --}}

{{ __('hosts.takeover.title', ['name' => $createdName]) }}

{{ __('hosts.takeover.subtitle') }}

@@ -71,58 +67,6 @@

{{ __('hosts.takeover.once_body') }}

-
    -
  1. -
    - 1 -

    {{ __('hosts.takeover.step1_title') }}

    -
    -

    {{ __('hosts.takeover.step1_body') }}

    -
  2. - -
  3. -
    - 2 -

    {{ __('hosts.takeover.step2_title') }}

    -
    -

    {{ __('hosts.takeover.step2_body') }}

    - -
    -
    - {{-- Umbrechen statt waagerecht rollen: aus einem Kasten - mit Rollbalken markiert jemand die Hälfte und merkt - es erst auf der Maschine. --}} -
    {{ $command }}
    - -
    -

    {{ __('hosts.takeover.step2_hint', ['url' => $archiveUrl]) }}

    -
    -
  4. - -
  5. -
    - 3 -

    {{ __('hosts.takeover.step3_title') }}

    -
    -

    {{ __('hosts.takeover.step3_body') }}

    - -
  6. -
- -

{{ __('hosts.takeover.footnote') }}

+ @endif
diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index 038af2c..aeafed3 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -9,6 +9,21 @@ + {{-- Die Anleitung gehört hierher, nicht nur auf die Anlegen-Seite: ihre + ersten zwei Schritte passieren beim Anbieter, und wer erst auf „Host + hinzufügen" klickt, hat den Server womöglich noch gar nicht bestellt. + Zusammengeklappt, damit sie die Liste nicht verdrängt — aber auffindbar, + ohne dass man vorher etwas anlegen muss. --}} +
+ + + {{ __('hosts.takeover.how_title') }} + + + + +
+ {{-- Filter bar — scales to many hosts (search + datacenter + status). --}}
diff --git a/tests/Feature/Admin/HostTakeoverGuideTest.php b/tests/Feature/Admin/HostTakeoverGuideTest.php new file mode 100644 index 0000000..ff1ba60 --- /dev/null +++ b/tests/Feature/Admin/HostTakeoverGuideTest.php @@ -0,0 +1,73 @@ +set('admin_access.app_host', 'clupilot.cloud'); +}); + +function guidePage(string $component): \Livewire\Features\SupportTesting\Testable +{ + return Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')->test($component); +} + +/** + * Der eigentliche Fund, der diese Datei nötig gemacht hat. + * + * Die erste Fassung zeigte die Anleitung ERST NACH dem Anlegen. Ihre Schritte 1 + * und 2 passieren aber beim Anbieter — Server bestellen, Rettungssystem starten + * —, und wer sie dort zum ersten Mal liest, hat den Einmal-Code schon in der + * Zwischenablage und dessen 24-Stunden-Frist läuft bereits. + */ +it('explains the whole procedure before anything is created', function () { + guidePage(HostCreate::class) + ->assertSee(__('hosts.takeover.s1_title')) + ->assertSee(__('hosts.takeover.s2_title')) + ->assertSee(__('hosts.takeover.s3_title')) + ->assertSee(__('hosts.takeover.s4_title')) + ->assertSee(__('hosts.takeover.s5_title')) + ->assertSee(__('hosts.takeover.s6_title')); +}); + +/** + * Und schon auf der Liste, nicht erst hinter „Host hinzufügen": wer dort steht, + * hat den Server womöglich noch gar nicht bestellt — und genau das ist Schritt 1. + */ +it('offers the same procedure on the hosts list', function () { + guidePage(Hosts::class) + ->assertSee(__('hosts.takeover.how_title')) + ->assertSee(__('hosts.takeover.s1_title')) + ->assertSee(__('hosts.takeover.s2_title')); +}); + +/** + * Die zwei Schritte beim Anbieter sind als solche gekennzeichnet. Ohne die + * Markierung liest sich die Liste, als könne man oben anfangen und sich + * durcharbeiten — und Schritt 2 ist dann eine halbe Stunde Wartezeit mitten + * in einem laufenden Code. + */ +it('marks the two provider steps as things to do first', function () { + guidePage(HostCreate::class)->assertSee(__('hosts.takeover.badge_before')); +}); + +/** + * Die Anleitung nennt die Adresse, von der geladen wird, und es muss dieselbe + * sein, die später in der Zeile steht. Zwei Fassungen davon liefen auseinander, + * und der Unterschied fiele auf einem Server auf, der schon bestellt ist. + */ +it('names the same archive address the command will use', function () { + guidePage(HostCreate::class)->assertSee('clupilot.cloud/bootstrap.tar.gz'); + guidePage(Hosts::class)->assertSee('clupilot.cloud/bootstrap.tar.gz'); +}); + +/** + * Vor dem Anlegen gibt es keine Befehlszeile — sonst stünde dort eine, die zu + * keinem Host gehört. + */ +it('shows no command until a host exists', function () { + guidePage(HostCreate::class)->assertDontSee('--wg-private'); +}); diff --git a/tests/Feature/Admin/HostTakeoverPageTest.php b/tests/Feature/Admin/HostTakeoverPageTest.php index e0ec0bd..085565c 100644 --- a/tests/Feature/Admin/HostTakeoverPageTest.php +++ b/tests/Feature/Admin/HostTakeoverPageTest.php @@ -122,14 +122,16 @@ it('warns before creating a host when the tunnel settings are missing', function }); /** - * Die drei Schritte sind die Anleitung. Fehlt einer, fehlt genau der, den - * jemand nicht von selbst weiß — meistens das Rettungssystem. + * Nach dem Anlegen bleibt dieselbe Anleitung stehen, nur mit der Zeile darin. + * Ein Betreiber, der bei Schritt 5 nicht weiterweiß, soll nicht auf einer Seite + * landen, die nur noch aus einem Kasten besteht. */ -it('spells out the three steps, rescue system first', function () { +it('keeps the whole procedure visible after the host exists', function () { createHostAs() - ->assertSee(__('hosts.takeover.step1_title')) - ->assertSee(__('hosts.takeover.step2_title')) - ->assertSee(__('hosts.takeover.step3_title')); + ->assertSee(__('hosts.takeover.s1_title')) + ->assertSee(__('hosts.takeover.s5_title')) + ->assertSee(__('hosts.takeover.s6_title')) + ->assertSee('--wg-private', escape: false); }); it('refuses to create a host without the permission', function () {