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
parent
b44d25404d
commit
d3d686e575
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -27,21 +27,93 @@
|
|||
</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
|
||||
{{-- 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
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 animate-rise">
|
||||
{{-- Health --}}
|
||||
<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.health') }}</p>
|
||||
<p class="mt-2 flex items-center gap-2 text-lg font-semibold {{ $htone }}">
|
||||
<span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
|
||||
{{ __('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>
|
||||
@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')
|
||||
<div class="flex items-start gap-3 rounded-xl border border-danger-border bg-danger-bg p-4 animate-rise">
|
||||
|
|
|
|||
|
|
@ -9,47 +9,92 @@
|
|||
</a>
|
||||
</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())
|
||||
<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>
|
||||
<p class="mt-3 text-sm text-muted">{{ $total === 0 ? __('hosts.empty') : __('hosts.no_matches') }}</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 }} · {{ $host->datacenter }}</p>
|
||||
</div>
|
||||
<x-ui.badge :status="$badge" class="ml-auto">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
|
||||
</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>
|
||||
</a>
|
||||
@endforeach
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:80ms]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.host') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.datacenter') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.health') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.instances') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.capacity') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@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
|
||||
<tr wire:key="host-{{ $host->uuid }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ route('admin.hosts.show', $host) }}" wire:navigate class="flex items-center gap-3">
|
||||
<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 class="min-w-0">
|
||||
<span class="block font-mono font-semibold text-ink">{{ $host->name }}</span>
|
||||
<span class="block font-mono text-xs text-muted">{{ $host->public_ip }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-body">{{ $host->datacenter }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-body">
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue