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
parent
ac3d17cd6a
commit
8d12a40a42
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -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"/>',
|
||||
'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"/>',
|
||||
'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] ?? '';
|
||||
@endphp
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@
|
|||
<span x-text="msg"></span>
|
||||
</div>
|
||||
|
||||
@livewire('wire-elements-modal')
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,36 +1,55 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.hosts') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin.hosts_sub') }}</p>
|
||||
<div class="flex items-start justify-between gap-4 animate-rise">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@php $hd = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]', '[animation-delay:240ms]']; @endphp
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@foreach ($rows as $i => $h)
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $hd[$i] ?? '' }}">
|
||||
<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>
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono font-semibold text-ink">{{ $h['name'] }}</p>
|
||||
<p class="font-mono text-xs text-muted">{{ $h['ip'] }} · {{ $h['instances'] }} {{ __('admin.instances_label') }}</p>
|
||||
</div>
|
||||
<x-ui.badge :status="$h['status'] === 'warning' ? 'warning' : 'active'" class="ml-auto">{{ __('admin.status.'.($h['status'] === 'warning' ? 'warning' : 'active')) }}</x-ui.badge>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
@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">
|
||||
@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
|
||||
<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">
|
||||
<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">
|
||||
<p class="font-mono font-semibold text-ink">{{ $host->name }}</p>
|
||||
<p class="font-mono text-xs text-muted">{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}</p>
|
||||
</div>
|
||||
<x-ui.badge :status="$badge" class="ml-auto">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
|
||||
</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 class="mt-4">
|
||||
<div class="flex justify-between text-xs">
|
||||
<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="h-full rounded-pill {{ $usedPct >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $usedPct }}%"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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">​</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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
Loading…
Reference in New Issue