From 8d12a40a42697adb006eebbea478a0c5b3463181 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 10:12:41 +0200 Subject: [PATCH] feat(admin): host onboarding UI (add / live stepper / retry / remove) Real hosts list, add-host form (StartHostOnboarding), host detail with live progress stepper (Reverb + wire:poll fallback), retry failed run, remove via wire-elements/modal (deregister only). DE+EN. 8 tests. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Admin/ConfirmRemoveHost.php | 40 +++++++ app/Livewire/Admin/HostCreate.php | 40 +++++++ app/Livewire/Admin/HostDetail.php | 89 +++++++++++++++ app/Livewire/Admin/Hosts.php | 8 +- lang/de/hosts.php | 80 +++++++++++++ lang/en/hosts.php | 80 +++++++++++++ resources/views/components/ui/icon.blade.php | 3 + resources/views/layouts/admin.blade.php | 2 + .../admin/confirm-remove-host.blade.php | 17 +++ .../livewire/admin/host-create.blade.php | 39 +++++++ .../livewire/admin/host-detail.blade.php | 92 +++++++++++++++ .../views/livewire/admin/hosts.blade.php | 75 +++++++----- .../wire-elements-modal/modal.blade.php | 57 ++++++++++ routes/web.php | 2 + tests/Feature/Admin/HostManagementTest.php | 107 ++++++++++++++++++ 15 files changed, 697 insertions(+), 34 deletions(-) create mode 100644 app/Livewire/Admin/ConfirmRemoveHost.php create mode 100644 app/Livewire/Admin/HostCreate.php create mode 100644 app/Livewire/Admin/HostDetail.php create mode 100644 lang/de/hosts.php create mode 100644 lang/en/hosts.php create mode 100644 resources/views/livewire/admin/confirm-remove-host.blade.php create mode 100644 resources/views/livewire/admin/host-create.blade.php create mode 100644 resources/views/livewire/admin/host-detail.blade.php create mode 100644 resources/views/vendor/wire-elements-modal/modal.blade.php create mode 100644 tests/Feature/Admin/HostManagementTest.php diff --git a/app/Livewire/Admin/ConfirmRemoveHost.php b/app/Livewire/Admin/ConfirmRemoveHost.php new file mode 100644 index 0000000..2c37e56 --- /dev/null +++ b/app/Livewire/Admin/ConfirmRemoveHost.php @@ -0,0 +1,40 @@ +uuid = $uuid; + $this->name = Host::query()->where('uuid', $uuid)->value('name') ?? ''; + } + + public function remove() + { + $host = Host::query()->where('uuid', $this->uuid)->first(); + + if ($host !== null) { + $host->runs()->delete(); // cascades run_resources + step_events + $host->delete(); + } + + return $this->redirectRoute('admin.hosts', navigate: true); + } + + public function render() + { + return view('livewire.admin.confirm-remove-host'); + } +} diff --git a/app/Livewire/Admin/HostCreate.php b/app/Livewire/Admin/HostCreate.php new file mode 100644 index 0000000..21780f6 --- /dev/null +++ b/app/Livewire/Admin/HostCreate.php @@ -0,0 +1,40 @@ +validate(); + + $host = $action->run($data); + + return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true); + } + + public function render() + { + return view('livewire.admin.host-create', [ + 'datacenters' => ['fsn', 'hel'], + ]); + } +} diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php new file mode 100644 index 0000000..aa5432a --- /dev/null +++ b/app/Livewire/Admin/HostDetail.php @@ -0,0 +1,89 @@ +host = $host; + } + + /** Live refresh whenever any run advances (admins-only channel). */ + #[On('echo-private:admin.runs,StepAdvanced')] + public function onStepAdvanced(): void + { + $this->host->refresh(); + } + + public function retry(): void + { + $run = $this->currentRun(); + + if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) { + $run->update([ + 'status' => ProvisioningRun::STATUS_RUNNING, + 'attempt' => 0, + 'next_attempt_at' => now(), + 'error' => null, + ]); + $this->host->update(['status' => 'onboarding']); + AdvanceRunJob::dispatch($run->uuid); + } + } + + private function currentRun(): ?ProvisioningRun + { + return $this->host->runs()->latest('id')->first(); + } + + /** @return array */ + private function buildSteps(?ProvisioningRun $run): array + { + $pipeline = config('provisioning.pipelines.host', []); + $current = $run?->current_step ?? 0; + $status = $run?->status; + + $steps = []; + foreach ($pipeline as $index => $class) { + if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) { + $state = 'done'; + } elseif ($index === $current) { + $state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running'; + } else { + $state = 'pending'; + } + + $steps[] = ['label' => __(app($class)->label()), 'state' => $state]; + } + + return $steps; + } + + public function render() + { + $run = $this->currentRun(); + + /** @var Collection $events */ + $events = $run + ? $run->events()->latest('id')->limit(30)->get() + : collect(); + + return view('livewire.admin.host-detail', [ + 'run' => $run, + 'steps' => $this->buildSteps($run), + 'events' => $events, + ]); + } +} diff --git a/app/Livewire/Admin/Hosts.php b/app/Livewire/Admin/Hosts.php index 89e45c5..e5abe12 100644 --- a/app/Livewire/Admin/Hosts.php +++ b/app/Livewire/Admin/Hosts.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin; +use App\Models\Host; use Livewire\Attributes\Layout; use Livewire\Component; @@ -11,12 +12,7 @@ class Hosts extends Component public function render() { return view('livewire.admin.hosts', [ - 'rows' => [ - ['name' => 'pve-fsn-1', 'ip' => '10.10.0.11', 'instances' => 12, 'used' => 72, 'cpu' => 58, 'status' => 'active'], - ['name' => 'pve-fsn-2', 'ip' => '10.10.0.12', 'instances' => 11, 'used' => 64, 'cpu' => 47, 'status' => 'active'], - ['name' => 'pve-fsn-3', 'ip' => '10.10.0.13', 'instances' => 13, 'used' => 81, 'cpu' => 74, 'status' => 'warning'], - ['name' => 'pve-hel-1', 'ip' => '10.20.0.11', 'instances' => 3, 'used' => 38, 'cpu' => 22, 'status' => 'active'], - ], + 'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(), ]); } } diff --git a/lang/de/hosts.php b/lang/de/hosts.php new file mode 100644 index 0000000..d318246 --- /dev/null +++ b/lang/de/hosts.php @@ -0,0 +1,80 @@ + 'Hosts', + 'subtitle' => 'Proxmox-Hosts und Kapazität.', + 'add' => 'Host hinzufügen', + 'empty' => 'Noch keine Hosts. Füge deinen ersten Server hinzu.', + + 'create_title' => 'Host hinzufügen', + 'create_sub' => 'Frischen Server (Debian, nur Root) automatisch als Proxmox-Host aufnehmen.', + 'back' => 'Zurück zu Hosts', + 'cancel' => 'Abbrechen', + 'save' => 'Onboarding starten', + + 'field' => [ + 'name' => 'Name', + 'name_hint' => 'z. B. pve-fsn-4', + 'datacenter' => 'Rechenzentrum', + 'public_ip' => 'Öffentliche IP', + 'root_password' => 'Root-Passwort (einmalig)', + 'root_password_hint' => 'Nur für den ersten Login. Wird nach dem Deploy des SSH-Schlüssels verworfen — nie dauerhaft gespeichert.', + ], + + 'datacenter' => [ + 'fsn' => 'Falkenstein', + 'hel' => 'Helsinki', + ], + + 'capacity' => 'Kapazität', + 'free' => 'frei', + 'unknown' => '—', + 'meta' => [ + 'cores' => 'Kerne', + 'ram' => 'RAM', + 'version' => 'Version', + 'wg_ip' => 'Mgmt-IP', + 'datacenter' => 'RZ', + ], + + 'status' => [ + 'pending' => 'Ausstehend', + 'onboarding' => 'Onboarding', + 'active' => 'Aktiv', + 'error' => 'Fehler', + 'disabled' => 'Deaktiviert', + ], + + 'progress' => 'Fortschritt', + 'events' => 'Ereignisse', + 'no_run' => 'Für diesen Host läuft keine Bereitstellung.', + 'attempt' => 'Versuch', + 'retry' => 'Erneut versuchen', + 'error_title' => 'Onboarding fehlgeschlagen', + + '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.', + 'remove_confirm' => 'Entfernen', + + 'outcome' => [ + 'advanced' => 'Weiter', + 'retry' => 'Wiederholung', + 'failed' => 'Fehler', + 'info' => 'Info', + ], + + 'step' => [ + 'validate_host_input' => 'Eingaben prüfen', + 'establish_ssh_trust' => 'SSH-Vertrauen herstellen', + 'prepare_base_system' => 'Basissystem vorbereiten', + 'configure_wireguard' => 'WireGuard einrichten', + 'install_proxmox_ve' => 'Proxmox VE installieren', + 'reboot_into_pve_kernel' => 'In Proxmox-Kernel neu starten', + 'configure_proxmox' => 'Proxmox konfigurieren', + 'create_automation_token' => 'Automation-Token erstellen', + 'verify_proxmox_api' => 'Proxmox-API prüfen', + 'register_capacity' => 'Kapazität registrieren', + 'complete_host_onboarding' => 'Onboarding abschließen', + ], +]; diff --git a/lang/en/hosts.php b/lang/en/hosts.php new file mode 100644 index 0000000..72f5a72 --- /dev/null +++ b/lang/en/hosts.php @@ -0,0 +1,80 @@ + 'Hosts', + 'subtitle' => 'Proxmox hosts and capacity.', + 'add' => 'Add host', + 'empty' => 'No hosts yet. Add your first server.', + + 'create_title' => 'Add host', + 'create_sub' => 'Onboard a fresh server (Debian, root only) as a Proxmox host automatically.', + 'back' => 'Back to hosts', + 'cancel' => 'Cancel', + 'save' => 'Start onboarding', + + 'field' => [ + 'name' => 'Name', + 'name_hint' => 'e.g. pve-fsn-4', + 'datacenter' => 'Datacenter', + 'public_ip' => 'Public IP', + 'root_password' => 'Root password (one-time)', + 'root_password_hint' => 'Used for the first login only. Discarded after the SSH key is deployed — never stored permanently.', + ], + + 'datacenter' => [ + 'fsn' => 'Falkenstein', + 'hel' => 'Helsinki', + ], + + 'capacity' => 'Capacity', + 'free' => 'free', + 'unknown' => '—', + 'meta' => [ + 'cores' => 'Cores', + 'ram' => 'RAM', + 'version' => 'Version', + 'wg_ip' => 'Mgmt IP', + 'datacenter' => 'DC', + ], + + 'status' => [ + 'pending' => 'Pending', + 'onboarding' => 'Onboarding', + 'active' => 'Active', + 'error' => 'Error', + 'disabled' => 'Disabled', + ], + + 'progress' => 'Progress', + 'events' => 'Events', + 'no_run' => 'No provisioning run for this host.', + 'attempt' => 'Attempt', + 'retry' => 'Retry', + 'error_title' => 'Onboarding failed', + + 'remove' => 'Remove host', + 'remove_title' => 'Remove host?', + 'remove_body' => 'Removes only the CluPilot record for :name. The physical server is NOT deleted or wiped.', + 'remove_confirm' => 'Remove', + + 'outcome' => [ + 'advanced' => 'Advanced', + 'retry' => 'Retry', + 'failed' => 'Failed', + 'info' => 'Info', + ], + + 'step' => [ + 'validate_host_input' => 'Validate input', + 'establish_ssh_trust' => 'Establish SSH trust', + 'prepare_base_system' => 'Prepare base system', + 'configure_wireguard' => 'Configure WireGuard', + 'install_proxmox_ve' => 'Install Proxmox VE', + 'reboot_into_pve_kernel' => 'Reboot into Proxmox kernel', + 'configure_proxmox' => 'Configure Proxmox', + 'create_automation_token' => 'Create automation token', + 'verify_proxmox_api' => 'Verify Proxmox API', + 'register_capacity' => 'Register capacity', + 'complete_host_onboarding' => 'Complete onboarding', + ], +]; diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 4a9edb9..db6c28a 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -24,6 +24,9 @@ 'trending-up' => '', 'box' => '', 'bell' => '', + 'arrow-left' => '', + 'rotate-ccw' => '', + 'trash-2' => '', ]; $body = $icons[$name] ?? ''; @endphp diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 901ca62..7cf5d2f 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -91,6 +91,8 @@ + @livewire('wire-elements-modal') + @livewireScripts diff --git a/resources/views/livewire/admin/confirm-remove-host.blade.php b/resources/views/livewire/admin/confirm-remove-host.blade.php new file mode 100644 index 0000000..590e3f4 --- /dev/null +++ b/resources/views/livewire/admin/confirm-remove-host.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

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

+

{{ __('hosts.remove_body', ['name' => $name]) }}

+
+
+
+ {{ __('hosts.cancel') }} + + {{ __('hosts.remove_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/host-create.blade.php b/resources/views/livewire/admin/host-create.blade.php new file mode 100644 index 0000000..0fc3224 --- /dev/null +++ b/resources/views/livewire/admin/host-create.blade.php @@ -0,0 +1,39 @@ +
+
+ + {{ __('hosts.back') }} + +

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

+

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

+
+ +
+ + +
+ + + @error('datacenter')

{{ $message }}

@enderror +
+ + + + + +
+ + {{ __('hosts.cancel') }} + + + {{ __('hosts.save') }} + {{ __('hosts.save') }}… + +
+ +
diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php new file mode 100644 index 0000000..e64df21 --- /dev/null +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -0,0 +1,92 @@ +
status, ['pending', 'running', 'waiting'])) wire:poll.4s @endif> + @php + $badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info'; + @endphp + +
+
+ + {{ __('hosts.back') }} + +
+

{{ $host->name }}

+ {{ __('hosts.status.'.$host->status) }} +
+

{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}

+
+
+ @if ($run && $run->status === 'failed') + + {{ __('hosts.retry') }} + + @endif + + {{ __('hosts.remove') }} + +
+
+ + @if ($host->status === 'active') +
+ @foreach ([ + ['meta.cores', $host->cpu_cores], + ['meta.ram', $host->total_ram_mb ? round($host->total_ram_mb / 1024).' GB' : __('hosts.unknown')], + ['meta.wg_ip', $host->wg_ip ?? __('hosts.unknown')], + ['meta.version', $host->pve_version ?? __('hosts.unknown')], + ] as [$key, $value]) +
+

{{ __('hosts.'.$key) }}

+

{{ $value }}

+
+ @endforeach +
+ @endif + + @if ($run && $run->status === 'failed') +
+ +
+

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

+

{{ $run->error }}

+
+
+ @endif + +
+
+

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

+ @if ($run) + + @else +

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

+ @endif +
+ +
+

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

+ @if ($events->isEmpty()) +

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

+ @else +
    + @foreach ($events as $event) + @php + $tone = ['advanced' => 'text-success', 'retry' => 'text-warning', 'failed' => 'text-danger', 'info' => 'text-muted'][$event->outcome] ?? 'text-muted'; + @endphp +
  1. + +
    +

    {{ __('hosts.step.'.$event->step) }}

    +

    + {{ __('hosts.outcome.'.$event->outcome) }} + · {{ __('hosts.attempt') }} {{ $event->attempt }} + @if ($event->message) · {{ $event->message }} @endif +

    +
    +
  2. + @endforeach +
+ @endif +
+
+
diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index f741e23..f3a71bd 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -1,36 +1,55 @@
-
-

{{ __('admin.nav.hosts') }}

-

{{ __('admin.hosts_sub') }}

+
+
+

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

+

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

+
+ + {{ __('hosts.add') }} +
- @php $hd = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]', '[animation-delay:240ms]']; @endphp -
- @foreach ($rows as $i => $h) -
-
- -
-

{{ $h['name'] }}

-

{{ $h['ip'] }} · {{ $h['instances'] }} {{ __('admin.instances_label') }}

-
- {{ __('admin.status.'.($h['status'] === 'warning' ? 'warning' : 'active')) }} -
-
-
-
{{ __('admin.storage_used') }}{{ $h['used'] }}%
-
-
+ @if ($hosts->isEmpty()) +
+ +

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

+
+ @else +
+ @foreach ($hosts as $host) + @php + $badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info'; + $usedPct = $host->total_gb ? (int) round(($host->total_gb - $host->freeGb()) / max($host->total_gb, 1) * 100) : 0; + @endphp + +
+ +
+

{{ $host->name }}

+

{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}

+ {{ __('hosts.status.'.$host->status) }}
-
-
CPU{{ $h['cpu'] }}%
-
-
+
+
+ {{ __('hosts.capacity') }} + + @if ($host->total_gb) + {{ $host->freeGb() }} / {{ $host->total_gb }} GB {{ __('hosts.free') }} + @else + {{ __('hosts.unknown') }} + @endif +
+ @if ($host->total_gb) +
+
+
+ @endif
-
-
- @endforeach -
+ + @endforeach +
+ @endif
diff --git a/resources/views/vendor/wire-elements-modal/modal.blade.php b/resources/views/vendor/wire-elements-modal/modal.blade.php new file mode 100644 index 0000000..6916e8c --- /dev/null +++ b/resources/views/vendor/wire-elements-modal/modal.blade.php @@ -0,0 +1,57 @@ +
+ @isset($jsPath) + + @endisset + @isset($cssPath) + + @endisset + + +
diff --git a/routes/web.php b/routes/web.php index 6948f8e..2315313 100644 --- a/routes/web.php +++ b/routes/web.php @@ -45,6 +45,8 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun Route::get('/customers', Admin\Customers::class)->name('customers'); 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'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); }); diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php new file mode 100644 index 0000000..f911b3e --- /dev/null +++ b/tests/Feature/Admin/HostManagementTest.php @@ -0,0 +1,107 @@ +create(['is_admin' => true]); +} + +it('gates the add-host page', function () { + $this->get(route('admin.hosts.create'))->assertRedirect('/login'); + $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.create'))->assertForbidden(); + $this->actingAs(admin())->get(route('admin.hosts.create'))->assertOk()->assertSee(__('hosts.create_title')); +}); + +it('gates the host detail page', function () { + $host = Host::factory()->create(); + $this->get(route('admin.hosts.show', $host))->assertRedirect('/login'); + $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.hosts.show', $host))->assertForbidden(); + $this->actingAs(admin())->get(route('admin.hosts.show', $host))->assertOk()->assertSee($host->name); +}); + +it('lists real hosts for admins', function () { + Host::factory()->active()->create(['name' => 'pve-zzz-1']); + + $this->actingAs(admin())->get(route('admin.hosts'))->assertOk()->assertSee('pve-zzz-1'); +}); + +it('creates a host and starts onboarding with an encrypted password', function () { + Queue::fake(); + + Livewire::actingAs(admin()) + ->test(HostCreate::class) + ->set('name', 'pve-fsn-7') + ->set('datacenter', 'fsn') + ->set('public_ip', '203.0.113.7') + ->set('root_password', 'supersecret') + ->call('save') + ->assertRedirect(); + + $host = Host::query()->where('name', 'pve-fsn-7')->first(); + expect($host)->not->toBeNull()->and($host->status)->toBe('pending'); + + $run = ProvisioningRun::query()->where('subject_id', $host->id)->first(); + expect($run)->not->toBeNull() + ->and($run->pipeline)->toBe('host') + ->and(Crypt::decryptString($run->context('root_password')))->toBe('supersecret'); + + Queue::assertPushed(AdvanceRunJob::class); +}); + +it('validates the add-host form', function () { + Livewire::actingAs(admin()) + ->test(HostCreate::class) + ->set('name', '') + ->set('public_ip', 'not-an-ip') + ->set('root_password', 'short') + ->call('save') + ->assertHasErrors(['name', 'public_ip', 'root_password']); +}); + +it('retries a failed run from the detail page', function () { + Queue::fake(); + $host = Host::factory()->create(['status' => 'error']); + $run = ProvisioningRun::factory()->forHost($host)->create([ + 'status' => 'failed', 'current_step' => 3, 'error' => 'boom', + ]); + + Livewire::actingAs(admin())->test(HostDetail::class, ['host' => $host])->call('retry'); + + $run->refresh(); + expect($run->status)->toBe('running') + ->and($run->error)->toBeNull() + ->and($host->fresh()->status)->toBe('onboarding'); + Queue::assertPushed(AdvanceRunJob::class); +}); + +it('removes the host record without wiping the server', function () { + $host = Host::factory()->create(); + ProvisioningRun::factory()->forHost($host)->create(); + + Livewire::actingAs(admin()) + ->test(ConfirmRemoveHost::class, ['uuid' => $host->uuid]) + ->call('remove') + ->assertRedirect(route('admin.hosts')); + + expect(Host::query()->find($host->id))->toBeNull(); +}); + +it('renders the live stepper for a running host', function () { + $host = Host::factory()->create(['status' => 'onboarding']); + ProvisioningRun::factory()->forHost($host)->create(['status' => 'running', 'current_step' => 2]); + + $this->actingAs(admin())->get(route('admin.hosts.show', $host)) + ->assertOk() + ->assertSee(__('hosts.step.prepare_base_system')) + ->assertSee(__('hosts.progress')); +});