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

198 lines
14 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 für jeden da
(`hosts.manage`). Eine Beschriftung, die
unabhängig davon immer dastünde, wäre eine
falsche Zusage.
Hier stand einmal „Berechtigung,
Betriebsbereitschaft" Letzteres entscheidet
seit dem Entsperren des Knopfes nichts mehr,
und zwanzig Zeilen tiefer begründet der
Kommentar am Knopf ausführlich das Gegenteil. --}}
<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>
{{-- `align-middle` an beiden: ein `inline-flex` holt sich seine
Grundlinie vom ERSTEN Kind, und das ist hier links ein
8px-Punkt und rechts ein 16px-Icon. Beide sitzen in einer
18,6px hohen Zeile mittig, ihre Unterkanten liegen deshalb
4px auseinander und genau so weit standen „Online" und
„Verbunden" versetzt, obwohl beide Zellen gleich hoch sind.
Nachgemessen: 361,3 gegen 358,5; mit `align-middle` beide
361,6. --}}
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 align-middle 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 align-middle 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">
{{-- Der Knopf steht auf JEDER Zeile, auch wenn dem Host noch die
Tunneladresse oder der Fingerabdruck fehlt.
Vorher verschwand er dann, mit der Begründung, ein Knopf, der
verlässlich in eine Ausnahme läuft, sei schlechter als gar
keiner. Das stimmte, solange die Seite dahinter mit einer
Laravel-Fehlerseite aufging. Sie tut es nicht mehr sie
schreibt hin, was fehlt (`HostTerminal::mount()`,
`hosts.terminal.problem.*`). Damit ist der fehlende Knopf die
schlechtere Auskunft von beiden: er sah aus wie „hier gibt es
kein Terminal", nicht wie „hier noch nicht, und zwar deshalb". --}}
@can('hosts.manage')
{{-- 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>
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>