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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 10:12:41 +02:00
parent ac3d17cd6a
commit 8d12a40a42
15 changed files with 697 additions and 34 deletions

View File

@ -0,0 +1,40 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for the destructive "remove host" action (R5). Deregisters the
* CluPilot record only it does not touch the physical server.
*/
class ConfirmRemoveHost extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$this->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');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\StartHostOnboarding;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostCreate extends Component
{
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|in:fsn,hel')]
public string $datacenter = 'fsn';
#[Validate('required|ip')]
public string $public_ip = '';
#[Validate('required|string|min:8')]
public string $root_password = '';
public function save(StartHostOnboarding $action)
{
$data = $this->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'],
]);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostDetail extends Component
{
public Host $host;
public function mount(Host $host): void
{
$this->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<int, array{label: string, state: string}> */
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,
]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use App\Models\Host;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
@ -11,12 +12,7 @@ class Hosts extends Component
public function render() public function render()
{ {
return view('livewire.admin.hosts', [ return view('livewire.admin.hosts', [
'rows' => [ 'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(),
['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'],
],
]); ]);
} }
} }

80
lang/de/hosts.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
'title' => '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',
],
];

80
lang/en/hosts.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
'title' => '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',
],
];

View File

@ -24,6 +24,9 @@
'trending-up' => '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>', 'trending-up' => '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'box' => '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>', 'box' => '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
'bell' => '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>', 'bell' => '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
'arrow-left' => '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
'rotate-ccw' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
]; ];
$body = $icons[$name] ?? ''; $body = $icons[$name] ?? '';
@endphp @endphp

View File

@ -91,6 +91,8 @@
<span x-text="msg"></span> <span x-text="msg"></span>
</div> </div>
@livewire('wire-elements-modal')
@livewireScripts @livewireScripts
</body> </body>
</html> </html>

View File

@ -0,0 +1,17 @@
<div class="rounded-xl bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('hosts.remove_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('hosts.remove_body', ['name' => $name]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('hosts.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="remove" wire:loading.attr="disabled">
<x-ui.icon name="trash-2" class="size-4" />{{ __('hosts.remove_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,39 @@
<div class="mx-auto max-w-xl space-y-5">
<div class="animate-rise">
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight text-ink">{{ __('hosts.create_title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.create_sub') }}</p>
</div>
<form wire:submit="save" class="space-y-5 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise">
<x-ui.input name="name" wire:model="name" :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" autofocus />
<div class="space-y-1.5">
<label for="datacenter" class="block text-sm font-medium text-body">{{ __('hosts.field.datacenter') }}</label>
<select id="datacenter" wire:model="datacenter"
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
@foreach ($datacenters as $dc)
<option value="{{ $dc }}">{{ __('hosts.datacenter.'.$dc) }} ({{ $dc }})</option>
@endforeach
</select>
@error('datacenter') <p class="text-xs text-danger">{{ $message }}</p> @enderror
</div>
<x-ui.input name="public_ip" wire:model="public_ip" :label="__('hosts.field.public_ip')" inputmode="numeric" placeholder="203.0.113.10" />
<x-ui.input name="root_password" wire:model="root_password" type="password"
:label="__('hosts.field.root_password')" :hint="__('hosts.field.root_password_hint')" autocomplete="off" />
<div class="flex items-center justify-end gap-3 pt-1">
<a href="{{ route('admin.hosts') }}" wire:navigate>
<x-ui.button variant="secondary" type="button">{{ __('hosts.cancel') }}</x-ui.button>
</a>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="save">{{ __('hosts.save') }}</span>
<span wire:loading wire:target="save">{{ __('hosts.save') }}</span>
</x-ui.button>
</div>
</form>
</div>

View File

@ -0,0 +1,92 @@
<div class="space-y-5" @if ($run && in_array($run->status, ['pending', 'running', 'waiting'])) wire:poll.4s @endif>
@php
$badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info';
@endphp
<div class="flex flex-wrap items-start justify-between gap-4 animate-rise">
<div class="min-w-0">
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a>
<div class="mt-2 flex items-center gap-3">
<h1 class="font-mono text-2xl font-semibold tracking-tight text-ink">{{ $host->name }}</h1>
<x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
</div>
<p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}</p>
</div>
<div class="flex items-center gap-3">
@if ($run && $run->status === 'failed')
<x-ui.button variant="secondary" size="sm" wire:click="retry" wire:loading.attr="disabled">
<x-ui.icon name="rotate-ccw" class="size-4" />{{ __('hosts.retry') }}
</x-ui.button>
@endif
<x-ui.button variant="danger" size="sm"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-remove-host', arguments: { uuid: '{{ $host->uuid }}' } })">
<x-ui.icon name="trash-2" class="size-4" />{{ __('hosts.remove') }}
</x-ui.button>
</div>
</div>
@if ($host->status === 'active')
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 animate-rise">
@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])
<div class="rounded-xl border border-line bg-surface p-4 shadow-xs">
<p class="text-xs text-muted">{{ __('hosts.'.$key) }}</p>
<p class="mt-1 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</p>
</div>
@endforeach
</div>
@endif
@if ($run && $run->status === 'failed')
<div class="flex items-start gap-3 rounded-xl border border-danger-border bg-danger-bg p-4 animate-rise">
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-danger" />
<div class="min-w-0">
<p class="text-sm font-semibold text-danger">{{ __('hosts.error_title') }}</p>
<p class="mt-0.5 break-words text-sm text-body">{{ $run->error }}</p>
</div>
</div>
@endif
<div class="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-3">
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.progress') }}</h2>
@if ($run)
<x-ui.progress-stepper :steps="$steps" />
@else
<p class="text-sm text-muted">{{ __('hosts.no_run') }}</p>
@endif
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-2">
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.events') }}</h2>
@if ($events->isEmpty())
<p class="text-sm text-muted">{{ __('hosts.no_run') }}</p>
@else
<ol class="space-y-3">
@foreach ($events as $event)
@php
$tone = ['advanced' => 'text-success', 'retry' => 'text-warning', 'failed' => 'text-danger', 'info' => 'text-muted'][$event->outcome] ?? 'text-muted';
@endphp
<li class="flex items-start gap-2.5 text-sm">
<span class="mt-1.5 size-1.5 shrink-0 rounded-pill bg-current {{ $tone }}" aria-hidden="true"></span>
<div class="min-w-0">
<p class="text-body">{{ __('hosts.step.'.$event->step) }}</p>
<p class="text-xs text-faint">
{{ __('hosts.outcome.'.$event->outcome) }}
· {{ __('hosts.attempt') }} {{ $event->attempt }}
@if ($event->message) · <span class="break-words">{{ $event->message }}</span> @endif
</p>
</div>
</li>
@endforeach
</ol>
@endif
</div>
</div>
</div>

View File

@ -1,36 +1,55 @@
<div class="space-y-5"> <div class="space-y-5">
<div class="animate-rise"> <div class="flex items-start justify-between gap-4 animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.hosts') }}</h1> <div>
<p class="mt-1 text-sm text-muted">{{ __('admin.hosts_sub') }}</p> <h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('hosts.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('hosts.subtitle') }}</p>
</div>
<a href="{{ route('admin.hosts.create') }}" wire:navigate>
<x-ui.button variant="primary" size="sm"><x-ui.icon name="plus" class="size-4" />{{ __('hosts.add') }}</x-ui.button>
</a>
</div> </div>
@php $hd = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]', '[animation-delay:240ms]']; @endphp @if ($hosts->isEmpty())
<div class="rounded-xl border border-dashed border-line-strong bg-surface p-10 text-center animate-rise">
<span class="mx-auto grid size-12 place-items-center rounded-lg bg-surface-2 text-muted"><x-ui.icon name="server" class="size-6" /></span>
<p class="mt-3 text-sm text-muted">{{ __('hosts.empty') }}</p>
</div>
@else
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@foreach ($rows as $i => $h) @foreach ($hosts as $host)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $hd[$i] ?? '' }}"> @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
<a href="{{ route('admin.hosts.show', $host) }}" wire:navigate
class="block rounded-xl border border-line bg-surface p-5 shadow-xs transition hover:bg-surface-hover animate-rise">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-text"><x-ui.icon name="server" class="size-5" /></span> <span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-text"><x-ui.icon name="server" class="size-5" /></span>
<div class="min-w-0"> <div class="min-w-0">
<p class="font-mono font-semibold text-ink">{{ $h['name'] }}</p> <p class="font-mono font-semibold text-ink">{{ $host->name }}</p>
<p class="font-mono text-xs text-muted">{{ $h['ip'] }} · {{ $h['instances'] }} {{ __('admin.instances_label') }}</p> <p class="font-mono text-xs text-muted">{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}</p>
</div> </div>
<x-ui.badge :status="$h['status'] === 'warning' ? 'warning' : 'active'" class="ml-auto">{{ __('admin.status.'.($h['status'] === 'warning' ? 'warning' : 'active')) }}</x-ui.badge> <x-ui.badge :status="$badge" class="ml-auto">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
</div> </div>
<div class="mt-4 space-y-3"> <div class="mt-4">
<div> <div class="flex justify-between text-xs">
<div class="flex justify-between text-xs"><span class="text-muted">{{ __('admin.storage_used') }}</span><span class="font-mono text-body">{{ $h['used'] }}%</span></div> <span class="text-muted">{{ __('hosts.capacity') }}</span>
<span class="font-mono text-body">
@if ($host->total_gb)
{{ $host->freeGb() }} / {{ $host->total_gb }} GB {{ __('hosts.free') }}
@else
{{ __('hosts.unknown') }}
@endif
</span>
</div>
@if ($host->total_gb)
<div class="mt-1 h-1.5 overflow-hidden rounded-pill bg-surface-2"> <div class="mt-1 h-1.5 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $h['used'] >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $h['used'] }}%"></div> <div class="h-full rounded-pill {{ $usedPct >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $usedPct }}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs"><span class="text-muted">CPU</span><span class="font-mono text-body">{{ $h['cpu'] }}%</span></div>
<div class="mt-1 h-1.5 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $h['cpu'] >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $h['cpu'] }}%"></div>
</div>
</div>
</div> </div>
@endif
</div> </div>
</a>
@endforeach @endforeach
</div> </div>
@endif
</div> </div>

View File

@ -0,0 +1,57 @@
<div>
@isset($jsPath)
<script>{!! file_get_contents($jsPath) !!}</script>
@endisset
@isset($cssPath)
<style>{!! file_get_contents($cssPath) !!}</style>
@endisset
<div
x-data="LivewireUIModal()"
x-on:close.stop="setShowPropertyTo(false)"
x-on:keydown.escape.window="show && closeModalOnEscape()"
x-show="show"
class="fixed inset-0 z-10 overflow-y-auto"
style="display: none;"
>
<div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">
<div
x-show="show"
x-on:click="closeModalOnClickAway()"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-all transform"
>
<div class="absolute inset-0 bg-black/60"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div
x-show="show && showActiveComponent"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-bind:class="modalWidth"
class="relative inline-block w-full align-bottom bg-surface rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:w-full"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
>
@forelse($components as $id => $component)
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
@livewire($component['name'], $component['arguments'], key($id))
</div>
@empty
@endforelse
</div>
</div>
</div>
</div>

View File

@ -45,6 +45,8 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::get('/customers', Admin\Customers::class)->name('customers'); Route::get('/customers', Admin\Customers::class)->name('customers');
Route::get('/instances', Admin\Instances::class)->name('instances'); Route::get('/instances', Admin\Instances::class)->name('instances');
Route::get('/hosts', Admin\Hosts::class)->name('hosts'); 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('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/revenue', Admin\Revenue::class)->name('revenue');
}); });

View File

@ -0,0 +1,107 @@
<?php
use App\Livewire\Admin\ConfirmRemoveHost;
use App\Livewire\Admin\HostCreate;
use App\Livewire\Admin\HostDetail;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Models\User;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
function admin(): User
{
return User::factory()->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'));
});