CluPilotCloud/resources/views/livewire/admin/host-detail.blade.php

282 lines
17 KiB
PHP

{{-- Zwei Taktungen, ein Aufruf: während der Übernahme zählt jede Sekunde, danach
schreibt Proxmox seine Messwerte ohnehin nur einmal pro Minute fort. Der
Zwischenspeicher in HostLoadSeries hält die schnelle Taktung vom Host fern. --}}
<div class="space-y-5" @if ($run && in_array($run->status, ['pending', 'running', 'waiting'])) wire:poll.4s @else wire:poll.60s @endif>
@php
$badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info';
@endphp
<div class="flex flex-wrap items-start justify-between gap-4 animate-rise">
<div class="min-w-0">
<a href="{{ route('admin.hosts') }}" wire:navigate class="inline-flex items-center gap-1.5 text-xs font-medium text-muted hover:text-body">
<x-ui.icon name="arrow-left" class="size-4" />{{ __('hosts.back') }}
</a>
<div class="mt-2 flex items-center gap-3">
<h1 class="font-mono text-2xl font-bold tracking-tight text-ink">{{ $host->name }}</h1>
<x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
</div>
<p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p>
</div>
<div class="flex items-center gap-3">
@if ($run && $run->status === 'failed')
<x-ui.button variant="secondary" size="sm" wire:click="retry" wire:loading.attr="disabled">
<x-ui.icon name="rotate-ccw" class="size-4" />{{ __('hosts.retry') }}
</x-ui.button>
@endif
@if (in_array($host->status, ['active', 'disabled'], true))
<x-ui.button variant="secondary" size="sm" wire:click="toggleMaintenance" wire:loading.attr="disabled">
<x-ui.icon name="{{ $host->status === 'active' ? 'shield' : 'check' }}" class="size-4" />
{{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }}
</x-ui.button>
@endif
{{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft
dieselbe, und zwei Fassungen liefen auseinander. --}}
@if (\App\Livewire\Admin\ReissueTakeover::eligible($host))
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'admin.reissue-takeover', arguments: { uuid: '{{ $host->uuid }}' } })">
<x-ui.icon name="refresh" class="size-4" />{{ __('hosts.reissue.action') }}
</x-ui.button>
@endif
<x-ui.button variant="danger" size="sm"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-remove-host', arguments: { uuid: '{{ $host->uuid }}' } })">
<x-ui.icon name="trash-2" class="size-4" />{{ __('hosts.remove') }}
</x-ui.button>
</div>
</div>
{{-- Health + resource overview --}}
@php
$hdot = ['online' => 'bg-success-bright', 'stale' => 'bg-warning', 'offline' => 'bg-danger'][$health];
// Die Scheibe hinter dem Punkt trägt dieselbe Aussage in Farbe. Vorher
// war sie bg-surface-2 auf weißem Grund — technisch da, sichtbar nicht,
// und damit eine Fläche, die nichts sagt.
$hdisc = ['online' => 'bg-success-bg', 'stale' => 'bg-warning-bg', 'offline' => 'bg-danger-bg'][$health];
$usedPct = $host->usedPct();
@endphp
{{-- Ein Kachelraster statt dreier ungleicher Kästen.
Vorher standen Zustand, Speicher und eine gemeinsame Kurve nebeneinander:
zwei davon halb leer, die dritte überfüllt, und die gemeinsame Kurve
brauchte eine Legende, um zu sagen, welche Linie welche ist.
Eine Reihe je Kachel löst alle drei Beschwerden auf einmal die
Beschriftung der Kachel benennt die Reihe, also braucht es keine
Legende; die Höhen sind durch das Raster gleich statt zufällig; und der
leere Platz ist mit Messwerten gefüllt, die es ohnehin schon gab. --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 animate-rise">
@php
$pct = fn (?float $v) => $v === null ? '—' : number_format($v, $v < 10 ? 1 : 0, ',', '.');
$mib = fn (?float $v) => $v === null ? '—' : number_format($v, 2, ',', '.');
@endphp
<x-ui.metric :label="__('hosts.detail.cpu_load')"
:value="$pct($now['cpu'])"
:unit="$now['cpu'] === null ? null : '%'"
:foot="$load['available'] ? __('hosts.detail.load_hint') : __('hosts.detail.no_load_short')">
@if ($load['available'])
<x-slot:visual><x-ui.spark :points="$load['cpu']" :min="0" :max="100" tone="muted" area width="120" height="40" /></x-slot:visual>
@endif
</x-ui.metric>
<x-ui.metric :label="__('hosts.detail.ram_load')"
:value="$pct($now['ram'])"
:unit="$now['ram'] === null ? null : '%'"
:foot="$load['available'] ? __('hosts.detail.load_hint') : __('hosts.detail.no_load_short')">
@if ($load['available'])
<x-slot:visual><x-ui.spark :points="$load['ram']" :min="0" :max="100" tone="muted" area width="120" height="40" /></x-slot:visual>
@endif
</x-ui.metric>
{{-- Die Ablage als Ring, wie die Vorlage die Festplatte zeichnet. Der
Ring zeigt BELEGT, die Zahl daneben FREI beides steht dabei, weil
ein Ring ohne Beschriftung beides bedeuten kann. --}}
<x-ui.metric :label="__('hosts.detail.storage')"
:value="$host->total_gb ? $host->availableGb() : '—'"
:unit="$host->total_gb ? '/ '.$host->freeGb().' GB '.__('hosts.free') : null"
:foot="$host->total_gb ? __('hosts.detail.committed', ['gb' => $host->committedGb()]) : __('hosts.unknown')">
@if ($host->total_gb)
<x-slot:visual><x-ui.ring :percent="$usedPct" /></x-slot:visual>
@endif
</x-ui.metric>
<x-ui.metric :label="__('hosts.detail.net_in')"
:value="$mib($now['netin'])"
:unit="$now['netin'] === null ? null : 'MiB/s'"
:foot="$load['available'] ? __('hosts.detail.load_hint') : __('hosts.detail.no_load_short')">
@if ($load['available'])
<x-slot:visual><x-ui.spark :points="$load['netin']" :min="0" tone="muted" area width="120" height="40" /></x-slot:visual>
@endif
</x-ui.metric>
<x-ui.metric :label="__('hosts.detail.net_out')"
:value="$mib($now['netout'])"
:unit="$now['netout'] === null ? null : 'MiB/s'"
:foot="$load['available'] ? __('hosts.detail.load_hint') : __('hosts.detail.no_load_short')">
@if ($load['available'])
<x-slot:visual><x-ui.spark :points="$load['netout']" :min="0" tone="muted" area width="120" height="40" /></x-slot:visual>
@endif
</x-ui.metric>
<x-ui.metric :label="__('hosts.detail.health')"
:value="__('hosts.health.'.$health)"
:foot="$host->last_seen_at ? __('hosts.detail.last_seen', ['time' => $host->last_seen_at->diffForHumans()]) : __('hosts.detail.never_seen')">
<x-slot:visual>
{{-- Halb so groß wie der Ring nebenan, nicht gleich groß: eine
gefüllte Scheibe wiegt optisch weit mehr als ein dünner
Ring, und bei 62 px erschlug sie die Kachel. --}}
<span class="grid size-8 place-items-center rounded-pill {{ $hdisc }}">
<span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
</span>
</x-slot:visual>
</x-ui.metric>
</div>
{{-- Ausstattung: was feststeht. Vorher standen diese Zahlen in Karten, die
wie die lebendigen aussahen deshalb las sich eine Kernzahl wie eine
Messung. --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="mb-3 text-xs font-semibold text-muted">{{ __('hosts.detail.spec') }}</h2>
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
@foreach ([
[__('hosts.detail.public_ip'), $host->public_ip ?? __('hosts.unknown')],
[__('hosts.meta.cores'), $host->cpu_cores ? (string) $host->cpu_cores : '—'],
[__('hosts.meta.ram'), $host->total_ram_mb ? round($host->total_ram_mb / 1024).' GB' : '—'],
[__('hosts.detail.node'), $host->node ?? __('hosts.unknown')],
[__('hosts.meta.wg_ip'), $host->wg_ip ?? __('hosts.unknown')],
] as [$label, $value])
<div class="min-w-0">
<dt class="text-xs text-muted">{{ $label }}</dt>
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink">{{ $value }}</dd>
</div>
@endforeach
<div class="min-w-0">
<dt class="text-xs text-muted">{{ __('hosts.meta.version') }}</dt>
@if ($version['version'])
{{-- Die Zahl, die jemand sucht. Die Bau-Kennung steht nur noch
im Titel: als eigene Zeile zwang sie die ganze Tafel in
eine zweite Reihe, für eine Zeichenkette, die fast
niemand liest und niemand abtippt. --}}
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink" title="{{ $host->pve_version }}">Proxmox VE {{ $version['version'] }}</dd>
@else
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink">{{ __('hosts.unknown') }}</dd>
@endif
</div>
</dl>
{{-- Die Reserve gehört hierher und nicht auf eine Kachel: die Kacheln
zeigen, was gemessen wurde, diese Tafel, was eingestellt ist. Ein
Eingabefeld zwischen Messwerten sähe aus, als ließe sich eine
Messung ändern. --}}
@if ($host->total_gb)
<div class="mt-5 flex flex-wrap items-center gap-2 border-t border-line pt-4" x-data="{ r: {{ $host->reserve_pct }} }">
<span class="text-xs text-muted">{{ __('hosts.detail.reserve_label') }}</span>
<input type="number" min="0" max="90" x-model.number="r" aria-label="{{ __('hosts.detail.reserve_label') }}"
class="w-16 rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-ink" />
<span class="text-xs text-muted">%</span>
<button type="button" x-on:click="$wire.saveReserve(r)" class="rounded-md border border-line px-2 py-1 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">{{ __('hosts.detail.reserve_save') }}</button>
<span class="text-xs text-muted">{{ __('hosts.detail.reserve_hint') }}</span>
</div>
@endif
</div>
{{-- Hosted instances. Die Anzahl steht in der Überschrift, statt als eigene
Kleinkarte neben der Liste, die sie ohnehin zeigt. --}}
<div class="rounded-lg 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') }}
<span class="ml-1 font-mono text-xs font-normal text-muted">{{ $instances->count() }}</span>
</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-lg border border-danger-border bg-danger-bg p-4 animate-rise">
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-danger" />
<div class="min-w-0">
<p class="text-sm font-semibold text-danger">{{ __('hosts.error_title') }}</p>
<p class="mt-0.5 break-words text-sm text-body">{{ $run->error }}</p>
</div>
</div>
@endif
@php
// Zugeklappt, sobald die Übernahme durch ist: fünfzehn abgehakte
// Schritte sind auf einem seit Wochen laufenden Host kein Dauerinhalt.
// Aufklappbar, nicht weg — wer nachsehen will, warum ein Schritt
// wiederholt wurde, findet ihn noch.
$onboardingDone = $run && $run->status === \App\Models\ProvisioningRun::STATUS_COMPLETED;
@endphp
<div class="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-3">
@if ($onboardingDone)
{{-- <details> statt Alpine: ein Aufklapper braucht kein
JavaScript, und der Inhalt bleibt für die Suche im Browser
und für Screenreader erreichbar. --}}
<details class="group">
<summary class="flex cursor-pointer list-none items-center gap-2 text-sm font-semibold text-ink">
<x-ui.icon name="check" class="size-4 shrink-0 text-success" />
<span>{{ __('hosts.detail.onboarding_done') }}</span>
<span class="ml-auto font-mono text-xs font-normal text-muted">
{{ $run->updated_at?->local()->isoFormat('D. MMM, HH:mm') }}
</span>
</summary>
<div class="mt-4">
<x-ui.progress-stepper :steps="$steps" />
</div>
</details>
@else
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.progress') }}</h2>
@if ($run)
<x-ui.progress-stepper :steps="$steps" />
@else
<p class="text-sm text-muted">{{ __('hosts.no_run') }}</p>
@endif
@endif
</div>
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise lg:col-span-2">
<h2 class="mb-4 text-sm font-semibold text-ink">{{ __('hosts.events') }}</h2>
@if ($events->isEmpty())
<p class="text-sm text-muted">{{ __('hosts.no_run') }}</p>
@else
<ol class="space-y-3">
@foreach ($events as $event)
@php
$tone = ['advanced' => 'text-success', 'retry' => 'text-warning', 'failed' => 'text-danger', 'info' => 'text-muted'][$event->outcome] ?? 'text-muted';
@endphp
<li class="flex items-start gap-2.5 text-sm">
<span class="mt-1.5 size-1.5 shrink-0 rounded-pill bg-current {{ $tone }}" aria-hidden="true"></span>
<div class="min-w-0">
<p class="text-body">{{ __('hosts.step.'.$event->step) }}</p>
<p class="text-xs text-muted">
{{ __('hosts.outcome.'.$event->outcome) }}
· {{ __('hosts.attempt') }} {{ $event->attempt }}
@if ($event->message) · <span class="break-words">{{ $event->message }}</span> @endif
</p>
</div>
</li>
@endforeach
</ol>
@endif
</div>
</div>
</div>