diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 19c753c..94defc2 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -85,6 +85,8 @@ class HostDetail extends Component 'run' => $run, 'steps' => $this->buildSteps($run), 'events' => $events, + 'instances' => $this->host->instances()->latest('id')->get(), + 'health' => $this->host->healthState(), ]); } } diff --git a/app/Livewire/Admin/Hosts.php b/app/Livewire/Admin/Hosts.php index e5abe12..62ce79b 100644 --- a/app/Livewire/Admin/Hosts.php +++ b/app/Livewire/Admin/Hosts.php @@ -2,17 +2,52 @@ namespace App\Livewire\Admin; +use App\Models\Datacenter; use App\Models\Host; use Livewire\Attributes\Layout; +use Livewire\Attributes\Url; use Livewire\Component; #[Layout('layouts.admin')] 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() { + $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', [ - '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(), ]); } } diff --git a/app/Models/Host.php b/app/Models/Host.php index e5eb059..2a29f52 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -81,6 +81,32 @@ class Host extends Model implements ProvisioningSubject return max(0, $this->freeGb() - $this->committedGb()); } + /** Used-storage percentage (committed vs committable free), 0–100. */ + 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 * committable storage. Cluster is ignored in v1.0. diff --git a/lang/de/hosts.php b/lang/de/hosts.php index d318246..93dfb4c 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -5,6 +5,25 @@ return [ 'subtitle' => 'Proxmox-Hosts und Kapazität.', 'add' => 'Host hinzufügen', '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_sub' => 'Frischen Server (Debian, nur Root) automatisch als Proxmox-Host aufnehmen.', @@ -45,6 +64,28 @@ return [ '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', 'events' => 'Ereignisse', 'no_run' => 'Für diesen Host läuft keine Bereitstellung.', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index 72f5a72..e0def10 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -5,6 +5,25 @@ return [ 'subtitle' => 'Proxmox hosts and capacity.', 'add' => 'Add host', '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_sub' => 'Onboard a fresh server (Debian, root only) as a Proxmox host automatically.', @@ -45,6 +64,28 @@ return [ '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', 'events' => 'Events', 'no_run' => 'No provisioning run for this host.', diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index 6b20d0b..bdfd1c9 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -27,21 +27,93 @@ - @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 + {{-- Health + resource overview --}} + @php + $hdot = ['online' => 'bg-success-bright', 'stale' => 'bg-warning', 'offline' => 'bg-danger'][$health]; + $htone = ['online' => 'text-success', 'stale' => 'text-warning', 'offline' => 'text-danger'][$health]; + $usedPct = $host->usedPct(); + @endphp +
+ {{-- Health --}} +
+

{{ __('hosts.detail.health') }}

+

+ + {{ __('hosts.health.'.$health) }} +

+

+ {{ $host->last_seen_at ? __('hosts.detail.last_seen', ['time' => $host->last_seen_at->diffForHumans()]) : __('hosts.detail.never_seen') }} +

- @endif + + {{-- Storage capacity --}} +
+

{{ __('hosts.detail.storage') }}

+ @if ($host->total_gb) +

{{ $host->availableGb() }} / {{ $host->freeGb() }} GB {{ __('hosts.free') }}

+
+
+
+

{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }} · {{ __('hosts.detail.reserve', ['pct' => $host->reserve_pct]) }}

+ @else +

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

+ @endif +
+ + {{-- Compute --}} +
+

{{ __('hosts.detail.compute') }}

+
+
+

{{ $host->cpu_cores ?? '—' }}

+

{{ __('hosts.meta.cores') }}

+
+
+

{{ $host->total_ram_mb ? round($host->total_ram_mb / 1024).' GB' : '—' }}

+

{{ __('hosts.meta.ram') }}

+
+
+
+
+ + {{-- Technical facts --}} +
+ @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]) +
+

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

+

{{ $value }}

+
+ @endforeach +
+ + {{-- Hosted instances --}} +
+

{{ __('hosts.detail.hosted') }}

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

{{ __('hosts.detail.no_instances') }}

+ @else +
+ + + @foreach ($instances as $inst) + @php $ibadge = ['active' => 'active', 'provisioning' => 'provisioning', 'failed' => 'failed', 'suspended' => 'suspended', 'cancellation_scheduled' => 'warning'][$inst->status] ?? 'info'; @endphp + + + + + + + @endforeach + +
{{ $inst->subdomain ?? '—' }}{{ __('billing.plan.'.$inst->plan) }}{{ $inst->disk_gb }} GB{{ __('hosts.istatus.'.$inst->status) }}
+
+ @endif +
@if ($run && $run->status === 'failed')
diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index 5c1e98c..3a8aef1 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -9,47 +9,92 @@
+ {{-- Filter bar — scales to many hosts (search + datacenter + status). --}} +
+
+ +
+ + + @if ($search !== '' || $datacenter !== '' || $status !== '') + + @endif + {{ __('hosts.count', ['shown' => $hosts->count(), 'total' => $total]) }} +
+ @if ($hosts->isEmpty())
-

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

+

{{ $total === 0 ? __('hosts.empty') : __('hosts.no_matches') }}

@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 }} · {{ $host->datacenter }}

-
- {{ __('hosts.status.'.$host->status) }} -
-
-
- {{ __('hosts.capacity') }} - - @if ($host->total_gb) - {{ $host->freeGb() }} / {{ $host->total_gb }} GB {{ __('hosts.free') }} - @else - {{ __('hosts.unknown') }} - @endif - -
- @if ($host->total_gb) -
-
-
- @endif -
-
- @endforeach +
+
+ + + + + + + + + + + + + @foreach ($hosts as $host) + @php + $badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info'; + $health = $host->healthState(); + $hdot = ['online' => 'bg-success-bright', 'stale' => 'bg-warning', 'offline' => 'bg-danger'][$health]; + $usedPct = $host->usedPct(); + @endphp + + + + + + + + + @endforeach + +
{{ __('hosts.col.host') }}{{ __('hosts.col.datacenter') }}{{ __('hosts.col.health') }}{{ __('hosts.col.instances') }}{{ __('hosts.col.capacity') }}{{ __('hosts.col.status') }}
+ + + + {{ $host->name }} + {{ $host->public_ip }} + + + {{ $host->datacenter }} + + {{ __('hosts.health.'.$health) }} + + {{ $host->instances_count }} + @if ($host->total_gb) +
+
+
+
+ {{ $host->availableGb() }} / {{ $host->freeGb() }} GB +
+ @else + {{ __('hosts.unknown') }} + @endif +
{{ __('hosts.status.'.$host->status) }}
+
@endif
diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index 88ae00b..8881dc7 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -3,6 +3,7 @@ use App\Livewire\Admin\ConfirmRemoveHost; use App\Livewire\Admin\HostCreate; use App\Livewire\Admin\HostDetail; +use App\Livewire\Admin\Hosts; use App\Models\Host; use App\Models\ProvisioningRun; 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.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'); +});