CluPilotCloud/resources/views/livewire/admin/hosts.blade.php

179 lines
13 KiB
PHP

{{-- Die Liste holt sich ihren Stand selbst.
Sie tat es nicht, und das fiel an zwei Stellen auf: Zustand, VPN und
Belegung standen so lange still, bis jemand neu lud und ein entfernter
Host blieb sichtbar, obwohl er schon weg war. Beides sah aus, als sei
nichts passiert.
Schneller, solange etwas in Arbeit ist: `ConfirmRemoveHost` setzt den Host
auf `disabled` und wirft `PurgeHost` in die Warteschlange, die erst auf die
Runner-Sperre wartet. In dieser Zeit steht die Zeile noch da, und ohne
kurzes Nachfassen bliebe sie es gefühlt für immer. Läuft nichts, reicht die
Minute die Seite soll nicht dauernd Abfragen erzeugen, nur weil sie offen
ist. --}}
<div class="space-y-5" @if ($busy) wire:poll.5s @else wire:poll.60s @endif>
<div class="flex items-start justify-between gap-4 animate-rise">
<div>
<h1 class="text-2xl font-bold 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>
{{-- Die Anleitung gehört hierher, nicht nur auf die Anlegen-Seite: ihre
ersten zwei Schritte passieren beim Anbieter, und wer erst auf „Host
hinzufügen" klickt, hat den Server womöglich noch gar nicht bestellt.
Zusammengeklappt, damit sie die Liste nicht verdrängt — aber auffindbar,
ohne dass man vorher etwas anlegen muss. --}}
<details class="group overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:20ms]">
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-6 py-3">
<span class="flex items-center gap-2.5 text-sm font-medium text-body">
<x-ui.icon name="life-buoy" class="size-4" />
{{ __('hosts.takeover.how_title') }}
<span class="hidden text-xs font-normal text-muted sm:inline"{{ __('hosts.takeover.how_sub') }}</span>
</span>
<x-ui.icon name="chevron-down" class="size-4 shrink-0 text-muted transition group-open:rotate-180" />
</summary>
<x-admin.takeover-steps class="border-t border-line" />
</details>
{{-- 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-muted" />
</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-lg 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">{{ $total === 0 ? __('hosts.empty') : __('hosts.no_matches') }}</p>
</div>
@else
<div class="overflow-hidden rounded-lg 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 font-semibold text-muted">
<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.vpn') }}</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>
{{-- Leer mit Absicht: die Spalte trägt nur den
Terminal-Knopf, und der ist nicht auf jeder
Zeile da (Berechtigung, Betriebsbereitschaft).
Eine Beschriftung, die unabhängig davon immer
dastünde, wäre eine falsche Zusage. --}}
<th class="px-4 py-3 font-semibold"></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();
$vpnPeer = $host->vpnPeer;
$vpnState = $host->vpnState($vpnPeer);
$vpnIcon = ['not_configured' => 'unlock', 'connected' => 'lock', 'silent' => 'alert-triangle', 'unknown' => 'life-buoy'][$vpnState];
$vpnTone = ['not_configured' => 'text-muted', 'connected' => 'text-success', 'silent' => 'text-warning', 'unknown' => 'text-muted'][$vpnState];
@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">
<span class="inline-flex items-center gap-1.5 text-xs {{ $vpnTone }}">
<x-ui.icon name="{{ $vpnIcon }}" class="size-4" />
@if ($vpnState === 'silent' && $vpnPeer?->last_handshake_at)
{{ __('hosts.vpn.silent_since', ['time' => $vpnPeer->last_handshake_at->diffForHumans()]) }}
@else
{{ __('hosts.vpn.'.$vpnState) }}
@endif
</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-muted">{{ __('hosts.unknown') }}</span>
@endif
{{-- Steht getrennt da, statt in der Zahl darüber zu verschwinden:
diese Maschine zählt nicht zum allgemeinen Bestand (Host::placeableIn()). --}}
@if ($host->reserved_for_customer_id)
<span class="mt-1 flex items-center gap-1 text-xs text-muted">
<x-ui.icon name="lock" class="size-3.5 shrink-0" />{{ __('hosts.reserved_badge', ['name' => $host->reservedFor?->name ?? __('hosts.unknown')]) }}
</span>
@endif
</td>
<td class="px-4 py-3"><x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge></td>
<td class="px-4 py-3 text-right">
{{-- Ohne Tunneladresse oder Fingerabdruck kann keine Sitzung
entstehen — TerminalTicket::issue() weist das mit einer
RuntimeException zurück (Aufgabe 1). Ein Knopf, der das
verlässlich auslöst, ist schlechter als gar keiner. --}}
@can('hosts.manage')
@if ($host->wg_ip && $host->ssh_host_key)
{{-- href direkt am Knopf, nicht als <a> drumherum: interaktiver
Inhalt in einem Link ist ungültiges HTML, und Mittelklick/
Statuszeile hingen sonst an einem Element, auf dem der
Zeiger nie landet (components/ui/button.blade.php Zeile 6-9). --}}
<x-ui.button variant="secondary" size="sm"
:href="route('admin.hosts.terminal', ['host' => $host->uuid])"
target="_blank" rel="noopener" :title="__('hosts.terminal.hint')">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
@endif
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>