feat(admin): scalable host list (search/filter/health) + host detail redesign

- host list: search + datacenter/status filters, dense table with heartbeat
  health dot, instance count, capacity meter
- host detail: health hero (last_seen), storage/compute breakdown, technical
  facts, hosted-instances table
- Host model: usedPct() + healthState() helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 14:27:53 +02:00
parent b44d25404d
commit d3d686e575
8 changed files with 342 additions and 51 deletions

View File

@ -85,6 +85,8 @@ class HostDetail extends Component
'run' => $run, 'run' => $run,
'steps' => $this->buildSteps($run), 'steps' => $this->buildSteps($run),
'events' => $events, 'events' => $events,
'instances' => $this->host->instances()->latest('id')->get(),
'health' => $this->host->healthState(),
]); ]);
} }
} }

View File

@ -2,17 +2,52 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host; use App\Models\Host;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
#[Layout('layouts.admin')] #[Layout('layouts.admin')]
class Hosts extends Component class Hosts extends Component
{ {
#[Url(as: 'q')]
public string $search = '';
#[Url]
public string $datacenter = '';
#[Url]
public string $status = '';
public function updated(): void
{
// no-op; keeps the query string in sync as filters change
}
public function clearFilters(): void
{
$this->reset('search', 'datacenter', 'status');
}
public function render() public function render()
{ {
$hosts = Host::query()
->withCount('instances')
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term));
})
->when($this->datacenter !== '', fn ($q) => $q->where('datacenter', $this->datacenter))
->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
->orderBy('datacenter')->orderBy('name')
->get();
return view('livewire.admin.hosts', [ return view('livewire.admin.hosts', [
'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(), 'hosts' => $hosts,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'],
'total' => Host::query()->count(),
]); ]);
} }
} }

View File

@ -81,6 +81,32 @@ class Host extends Model implements ProvisioningSubject
return max(0, $this->freeGb() - $this->committedGb()); return max(0, $this->freeGb() - $this->committedGb());
} }
/** Used-storage percentage (committed vs committable free), 0100. */
public function usedPct(): int
{
$free = $this->freeGb();
return $free > 0 ? min(100, (int) round($this->committedGb() / $free * 100)) : 0;
}
/**
* Heartbeat health from last_seen_at: online (≤5 min), stale (≤30 min),
* offline (older or never seen). Drives the health dot in the console.
*/
public function healthState(): string
{
if ($this->last_seen_at === null) {
return 'offline';
}
$minutes = $this->last_seen_at->diffInMinutes(now());
return match (true) {
$minutes <= 5 => 'online',
$minutes <= 30 => 'stale',
default => 'offline',
};
}
/** /**
* Placement (spec §1): first active host in the datacenter with enough free * Placement (spec §1): first active host in the datacenter with enough free
* committable storage. Cluster is ignored in v1.0. * committable storage. Cluster is ignored in v1.0.

View File

@ -5,6 +5,25 @@ return [
'subtitle' => 'Proxmox-Hosts und Kapazität.', 'subtitle' => 'Proxmox-Hosts und Kapazität.',
'add' => 'Host hinzufügen', 'add' => 'Host hinzufügen',
'empty' => 'Noch keine Hosts. Füge deinen ersten Server hinzu.', 'empty' => 'Noch keine Hosts. Füge deinen ersten Server hinzu.',
'no_matches' => 'Keine Hosts für diese Filter.',
'search_placeholder' => 'Nach Name oder IP suchen …',
'all_datacenters' => 'Alle Rechenzentren',
'all_statuses' => 'Alle Status',
'clear' => 'Zurücksetzen',
'count' => ':shown von :total',
'col' => [
'host' => 'Host',
'datacenter' => 'RZ',
'health' => 'Zustand',
'instances' => 'Instanzen',
'capacity' => 'Kapazität',
'status' => 'Status',
],
'health' => [
'online' => 'Online',
'stale' => 'Verzögert',
'offline' => 'Offline',
],
'create_title' => 'Host hinzufügen', 'create_title' => 'Host hinzufügen',
'create_sub' => 'Frischen Server (Debian, nur Root) automatisch als Proxmox-Host aufnehmen.', 'create_sub' => 'Frischen Server (Debian, nur Root) automatisch als Proxmox-Host aufnehmen.',
@ -45,6 +64,28 @@ return [
'disabled' => 'Deaktiviert', 'disabled' => 'Deaktiviert',
], ],
'detail' => [
'health' => 'Zustand',
'storage' => 'Speicher',
'compute' => 'Rechenleistung',
'node' => 'Node',
'instances' => 'Instanzen',
'hosted' => 'Gehostete Instanzen',
'no_instances' => 'Auf diesem Host laufen noch keine Instanzen.',
'last_seen' => 'Zuletzt gesehen :time',
'never_seen' => 'Noch nie gesehen',
'committed' => ':gb GB belegt',
'reserve' => ':pct % Reserve',
],
'istatus' => [
'active' => 'Aktiv',
'provisioning' => 'Bereitstellung',
'failed' => 'Fehler',
'suspended' => 'Ausgesetzt',
'cancellation_scheduled' => 'Kündigung',
],
'progress' => 'Fortschritt', 'progress' => 'Fortschritt',
'events' => 'Ereignisse', 'events' => 'Ereignisse',
'no_run' => 'Für diesen Host läuft keine Bereitstellung.', 'no_run' => 'Für diesen Host läuft keine Bereitstellung.',

View File

@ -5,6 +5,25 @@ return [
'subtitle' => 'Proxmox hosts and capacity.', 'subtitle' => 'Proxmox hosts and capacity.',
'add' => 'Add host', 'add' => 'Add host',
'empty' => 'No hosts yet. Add your first server.', 'empty' => 'No hosts yet. Add your first server.',
'no_matches' => 'No hosts match these filters.',
'search_placeholder' => 'Search by name or IP …',
'all_datacenters' => 'All datacenters',
'all_statuses' => 'All statuses',
'clear' => 'Clear',
'count' => ':shown of :total',
'col' => [
'host' => 'Host',
'datacenter' => 'DC',
'health' => 'Health',
'instances' => 'Instances',
'capacity' => 'Capacity',
'status' => 'Status',
],
'health' => [
'online' => 'Online',
'stale' => 'Stale',
'offline' => 'Offline',
],
'create_title' => 'Add host', 'create_title' => 'Add host',
'create_sub' => 'Onboard a fresh server (Debian, root only) as a Proxmox host automatically.', 'create_sub' => 'Onboard a fresh server (Debian, root only) as a Proxmox host automatically.',
@ -45,6 +64,28 @@ return [
'disabled' => 'Disabled', 'disabled' => 'Disabled',
], ],
'detail' => [
'health' => 'Health',
'storage' => 'Storage',
'compute' => 'Compute',
'node' => 'Node',
'instances' => 'Instances',
'hosted' => 'Hosted instances',
'no_instances' => 'No instances running on this host yet.',
'last_seen' => 'Last seen :time',
'never_seen' => 'Never seen',
'committed' => ':gb GB committed',
'reserve' => ':pct % reserve',
],
'istatus' => [
'active' => 'Active',
'provisioning' => 'Provisioning',
'failed' => 'Failed',
'suspended' => 'Suspended',
'cancellation_scheduled' => 'Cancelling',
],
'progress' => 'Progress', 'progress' => 'Progress',
'events' => 'Events', 'events' => 'Events',
'no_run' => 'No provisioning run for this host.', 'no_run' => 'No provisioning run for this host.',

View File

@ -27,21 +27,93 @@
</div> </div>
</div> </div>
@if ($host->status === 'active') {{-- Health + resource overview --}}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 animate-rise"> @php
@foreach ([ $hdot = ['online' => 'bg-success-bright', 'stale' => 'bg-warning', 'offline' => 'bg-danger'][$health];
['meta.cores', $host->cpu_cores], $htone = ['online' => 'text-success', 'stale' => 'text-warning', 'offline' => 'text-danger'][$health];
['meta.ram', $host->total_ram_mb ? round($host->total_ram_mb / 1024).' GB' : __('hosts.unknown')], $usedPct = $host->usedPct();
['meta.wg_ip', $host->wg_ip ?? __('hosts.unknown')], @endphp
['meta.version', $host->pve_version ?? __('hosts.unknown')], <div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise">
] as [$key, $value]) {{-- Health --}}
<div class="rounded-xl border border-line bg-surface p-4 shadow-xs"> <div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
<p class="text-xs text-muted">{{ __('hosts.'.$key) }}</p> <p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.health') }}</p>
<p class="mt-1 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</p> <p class="mt-2 flex items-center gap-2 text-lg font-semibold {{ $htone }}">
</div> <span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
@endforeach {{ __('hosts.health.'.$health) }}
</p>
<p class="mt-1 text-xs text-muted">
{{ $host->last_seen_at ? __('hosts.detail.last_seen', ['time' => $host->last_seen_at->diffForHumans()]) : __('hosts.detail.never_seen') }}
</p>
</div> </div>
@endif
{{-- Storage capacity --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.storage') }}</p>
@if ($host->total_gb)
<p class="mt-2 font-mono text-lg font-semibold text-ink">{{ $host->availableGb() }} <span class="text-sm font-normal text-muted">/ {{ $host->freeGb() }} GB {{ __('hosts.free') }}</span></p>
<div class="mt-2 h-2 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $usedPct >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $usedPct }}%"></div>
</div>
<p class="mt-1.5 text-xs text-muted">{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }} · {{ __('hosts.detail.reserve', ['pct' => $host->reserve_pct]) }}</p>
@else
<p class="mt-2 text-sm text-muted">{{ __('hosts.unknown') }}</p>
@endif
</div>
{{-- Compute --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('hosts.detail.compute') }}</p>
<div class="mt-2 grid grid-cols-2 gap-3">
<div>
<p class="font-mono text-lg font-semibold text-ink">{{ $host->cpu_cores ?? '—' }}</p>
<p class="text-xs text-muted">{{ __('hosts.meta.cores') }}</p>
</div>
<div>
<p class="font-mono text-lg font-semibold text-ink">{{ $host->total_ram_mb ? round($host->total_ram_mb / 1024).' GB' : '—' }}</p>
<p class="text-xs text-muted">{{ __('hosts.meta.ram') }}</p>
</div>
</div>
</div>
</div>
{{-- Technical facts --}}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 animate-rise [animation-delay:60ms]">
@foreach ([
['meta.wg_ip', $host->wg_ip ?? __('hosts.unknown')],
['detail.node', $host->node ?? __('hosts.unknown')],
['meta.version', $host->pve_version ?? __('hosts.unknown')],
['detail.instances', (string) $instances->count()],
] 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>
{{-- Hosted instances --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]">
<h2 class="mb-3 text-sm font-semibold text-ink">{{ __('hosts.detail.hosted') }}</h2>
@if ($instances->isEmpty())
<p class="text-sm text-muted">{{ __('hosts.detail.no_instances') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<tbody>
@foreach ($instances as $inst)
@php $ibadge = ['active' => 'active', 'provisioning' => 'provisioning', 'failed' => 'failed', 'suspended' => 'suspended', 'cancellation_scheduled' => 'warning'][$inst->status] ?? 'info'; @endphp
<tr class="border-b border-line last:border-0">
<td class="py-2.5 pr-4 font-mono text-ink">{{ $inst->subdomain ?? '—' }}</td>
<td class="py-2.5 pr-4 text-body">{{ __('billing.plan.'.$inst->plan) }}</td>
<td class="py-2.5 pr-4 font-mono text-xs text-muted">{{ $inst->disk_gb }} GB</td>
<td class="py-2.5 text-right"><x-ui.badge :status="$ibadge">{{ __('hosts.istatus.'.$inst->status) }}</x-ui.badge></td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@if ($run && $run->status === 'failed') @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"> <div class="flex items-start gap-3 rounded-xl border border-danger-border bg-danger-bg p-4 animate-rise">

View File

@ -9,47 +9,92 @@
</a> </a>
</div> </div>
{{-- Filter bar scales to many hosts (search + datacenter + status). --}}
<div class="flex flex-wrap items-center gap-2 animate-rise [animation-delay:40ms]">
<div class="relative min-w-48 flex-1">
<input type="search" wire:model.live.debounce.300ms="search" placeholder="{{ __('hosts.search_placeholder') }}"
class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint" />
</div>
<select wire:model.live="datacenter" class="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('hosts.all_datacenters') }}</option>
@foreach ($datacenters as $dc)
<option value="{{ $dc->code }}">{{ $dc->name }} ({{ $dc->code }})</option>
@endforeach
</select>
<select wire:model.live="status" class="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('hosts.all_statuses') }}</option>
@foreach ($statuses as $s)
<option value="{{ $s }}">{{ __('hosts.status.'.$s) }}</option>
@endforeach
</select>
@if ($search !== '' || $datacenter !== '' || $status !== '')
<button type="button" wire:click="clearFilters" class="rounded-md border border-line px-3 py-2 text-sm text-muted hover:bg-surface-hover">{{ __('hosts.clear') }}</button>
@endif
<span class="ml-auto text-xs text-muted">{{ __('hosts.count', ['shown' => $hosts->count(), 'total' => $total]) }}</span>
</div>
@if ($hosts->isEmpty()) @if ($hosts->isEmpty())
<div class="rounded-xl border border-dashed border-line-strong bg-surface p-10 text-center animate-rise"> <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> <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> <p class="mt-3 text-sm text-muted">{{ $total === 0 ? __('hosts.empty') : __('hosts.no_matches') }}</p>
</div> </div>
@else @else
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:80ms]">
@foreach ($hosts as $host) <div class="overflow-x-auto">
@php <table class="w-full text-sm">
$badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info'; <thead>
$usedPct = $host->total_gb ? (int) round(($host->total_gb - $host->freeGb()) / max($host->total_gb, 1) * 100) : 0; <tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
@endphp <th class="px-4 py-3 font-semibold">{{ __('hosts.col.host') }}</th>
<a href="{{ route('admin.hosts.show', $host) }}" wire:navigate <th class="px-4 py-3 font-semibold">{{ __('hosts.col.datacenter') }}</th>
class="block rounded-xl border border-line bg-surface p-5 shadow-xs transition hover:bg-surface-hover animate-rise"> <th class="px-4 py-3 font-semibold">{{ __('hosts.col.health') }}</th>
<div class="flex items-center gap-3"> <th class="px-4 py-3 font-semibold">{{ __('hosts.col.instances') }}</th>
<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> <th class="px-4 py-3 font-semibold">{{ __('hosts.col.capacity') }}</th>
<div class="min-w-0"> <th class="px-4 py-3 font-semibold">{{ __('hosts.col.status') }}</th>
<p class="font-mono font-semibold text-ink">{{ $host->name }}</p> </tr>
<p class="font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p> </thead>
</div> <tbody>
<x-ui.badge :status="$badge" class="ml-auto">{{ __('hosts.status.'.$host->status) }}</x-ui.badge> @foreach ($hosts as $host)
</div> @php
<div class="mt-4"> $badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info';
<div class="flex justify-between text-xs"> $health = $host->healthState();
<span class="text-muted">{{ __('hosts.capacity') }}</span> $hdot = ['online' => 'bg-success-bright', 'stale' => 'bg-warning', 'offline' => 'bg-danger'][$health];
<span class="font-mono text-body"> $usedPct = $host->usedPct();
@if ($host->total_gb) @endphp
{{ $host->freeGb() }} / {{ $host->total_gb }} GB {{ __('hosts.free') }} <tr wire:key="host-{{ $host->uuid }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
@else <td class="px-4 py-3">
{{ __('hosts.unknown') }} <a href="{{ route('admin.hosts.show', $host) }}" wire:navigate class="flex items-center gap-3">
@endif <span class="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-text"><x-ui.icon name="server" class="size-4" /></span>
</span> <span class="min-w-0">
</div> <span class="block font-mono font-semibold text-ink">{{ $host->name }}</span>
@if ($host->total_gb) <span class="block font-mono text-xs text-muted">{{ $host->public_ip }}</span>
<div class="mt-1 h-1.5 overflow-hidden rounded-pill bg-surface-2"> </span>
<div class="h-full rounded-pill {{ $usedPct >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $usedPct }}%"></div> </a>
</div> </td>
@endif <td class="px-4 py-3 font-mono text-xs text-body">{{ $host->datacenter }}</td>
</div> <td class="px-4 py-3">
</a> <span class="inline-flex items-center gap-1.5 text-xs text-body">
@endforeach <span class="size-2 rounded-pill {{ $hdot }}" aria-hidden="true"></span>{{ __('hosts.health.'.$health) }}
</span>
</td>
<td class="px-4 py-3 font-mono text-body">{{ $host->instances_count }}</td>
<td class="px-4 py-3">
@if ($host->total_gb)
<div class="flex items-center gap-2">
<div class="h-1.5 w-24 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $usedPct >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $usedPct }}%"></div>
</div>
<span class="font-mono text-xs text-muted">{{ $host->availableGb() }} / {{ $host->freeGb() }} GB</span>
</div>
@else
<span class="text-xs text-faint">{{ __('hosts.unknown') }}</span>
@endif
</td>
<td class="px-4 py-3"><x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div> </div>
@endif @endif
</div> </div>

View File

@ -3,6 +3,7 @@
use App\Livewire\Admin\ConfirmRemoveHost; use App\Livewire\Admin\ConfirmRemoveHost;
use App\Livewire\Admin\HostCreate; use App\Livewire\Admin\HostCreate;
use App\Livewire\Admin\HostDetail; use App\Livewire\Admin\HostDetail;
use App\Livewire\Admin\Hosts;
use App\Models\Host; use App\Models\Host;
use App\Models\ProvisioningRun; use App\Models\ProvisioningRun;
use App\Models\User; use App\Models\User;
@ -137,3 +138,31 @@ it('renders the live stepper for a running host', function () {
->assertSee(__('hosts.step.prepare_base_system')) ->assertSee(__('hosts.step.prepare_base_system'))
->assertSee(__('hosts.progress')); ->assertSee(__('hosts.progress'));
}); });
it('filters the host list by search and datacenter', function () {
\App\Models\Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
\App\Models\Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']);
Host::factory()->create(['name' => 'pve-fsn-9', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.9']);
Host::factory()->create(['name' => 'pve-hel-9', 'datacenter' => 'hel', 'public_ip' => '203.0.113.19']);
Livewire::actingAs(admin())->test(Hosts::class)
->set('search', 'fsn-9')
->assertSee('pve-fsn-9')
->assertDontSee('pve-hel-9')
->set('search', '')
->set('datacenter', 'hel')
->assertSee('pve-hel-9')
->assertDontSee('pve-fsn-9');
});
it('reports host heartbeat health from last_seen_at', function () {
$online = Host::factory()->create(['last_seen_at' => now()->subMinutes(2)]);
$stale = Host::factory()->create(['last_seen_at' => now()->subMinutes(20)]);
$offline = Host::factory()->create(['last_seen_at' => now()->subHours(3)]);
$never = Host::factory()->create(['last_seen_at' => null]);
expect($online->healthState())->toBe('online')
->and($stale->healthState())->toBe('stale')
->and($offline->healthState())->toBe('offline')
->and($never->healthState())->toBe('offline');
});