Release v1.3.92 — sechs Kacheln statt dreier ungleicher Kästen
tests / pest (push) Failing after 10m15s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details

Nach der Vorlage des Besitzers. Zustand, Speicher und die gemeinsame Kurve
standen 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 mehr. 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: Proxmox' Aufzeichnung liefert
Netzdurchsatz in beide Richtungen mit, ungefragt.

Sechs Kacheln: CPU-Auslastung, RAM-Auslastung, Speicher (als Ring), Eingehend,
Ausgehend, Zustand. Gebaut mit x-ui.metric, x-ui.spark und x-ui.ring — die
gab es alle schon, und der Kopfkommentar von x-ui.metric sagt selbst "exactly
as the approved template draws it". Nichts daneben neu gebaut.

Die öffentliche IP steht jetzt auf der Seite. Sie stand vorher NUR klein unter
der Überschrift — die Adresse, unter der der Host wirklich erreichbar ist, war
in der Detailseite nirgends ein Feld. Sie führt jetzt die Ausstattungs-Tafel
an, und die Reserve-Eingabe ist mit dorthin gezogen: die Kacheln zeigen, was
gemessen wurde, die Tafel, was eingestellt ist. Ein Eingabefeld zwischen
Messwerten sähe aus, als ließe sich eine Messung ändern.

Alle vier Verlaufslinien tragen denselben Ton. Die Regel steht im Bauteil
selbst — "muted where the figure is observed, accent where it can be acted on"
—, und hier ist keine Zahl anzufassen. Vier verschiedene Töne nebeneinander
behaupten einen Unterschied, den es nicht gibt.

Zwei Funde aus der Prüfung
--------------------------

- x-ui.spark warf fehlende Messwerte per array_filter heraus und verband die
  Nachbarn. Zwei Fehler auf einmal: die Linie behauptete eine Messung, die es
  nicht gab, und alles danach rutschte nach links — eine Stunde mit zwei Lücken
  zeichnete sich als achtundfünfzig Minuten. Die x-Lage kommt jetzt aus dem
  Platz in der URSPRÜNGLICHEN Reihe, und zusammenhängende Messwerte werden als
  eigene Züge gezeichnet. Eine saubere Reihe ergibt genau einen Zug und
  dasselbe Bild wie vorher, was alle bisherigen Aufrufer liefern.

- Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus v1.3.91 kennt
  netin/netout nicht, und die Host-Seite wäre 55 Sekunden lang an einem
  fehlenden Schlüssel gestorben — genau in der Minute, in der jemand nachsieht,
  ob das Update durch ist. Der Schlüssel heißt jetzt host-load:v2:<id> und
  wandert mit der Form mit.

Und einer, den kein Prüfer gemeldet hat: beim Zerlegen in Züge stand im
Flächenpfad ein `L` unmittelbar vor einem `M`. Gültig gelesen, nicht
gezeichnet — die Füllung verschwand still. Aufgefallen ist es beim Ansehen der
Seite, nicht durch eine Meldung; jetzt prüft ein Test, dass jeder Flächenpfad
mit M anfängt, mit Z endet und keinen Befehl direkt hinter einem anderen trägt.

x-ui.chart behält seinen update-on-Weg, obwohl diese Seite ihn nicht mehr
benutzt: er ist eine geprüfte Fähigkeit des gemeinsamen Bauteils, und der
darunterliegende Fix (Instanz aus dem reaktiven Alpine-Objekt) gilt für jeden
Chart.

Geprüft: 2291 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit
eingespielten Messwerten: sechs Kacheln, Lücke als echte Aussparung in Linie
UND Fläche, Leerzustand zeigt "—" statt einer Null, null Konsolenfehler über
einen vollen Poll-Zyklus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main v1.3.92
nexxo 2026-08-01 11:02:55 +02:00
parent 665a0c4e08
commit 589489a9fc
10 changed files with 352 additions and 197 deletions

View File

@ -1 +1 @@
1.3.91
1.3.92

View File

@ -7,7 +7,6 @@ use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Proxmox\HostLoadSeries;
use App\Support\PveVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
@ -30,26 +29,6 @@ class HostDetail extends Component
$this->host->refresh();
}
/**
* The polled tick: re-read the host and push fresh points into the live
* chart.
*
* The dispatch is the only way the curve moves. The canvas sits under
* wire:ignore, so re-rendering which this method also causes updates
* every figure on the page EXCEPT the chart. See x-ui.chart's `update-on`.
*/
public function refreshLoad(): void
{
$this->host->refresh();
$load = app(HostLoadSeries::class)->forHost($this->host);
$this->dispatch(
'host-load',
labels: $this->clockLabels($load['labels']),
datasets: [$load['cpu'], $load['ram']],
);
}
/** Adjust the capacity reserve (% of storage kept free for headroom). */
public function saveReserve(int $reserve): void
{
@ -121,82 +100,24 @@ class HostDetail extends Component
}
/**
* Unix seconds the wall clock the operator is reading it on (R19).
* The most recent reading that is actually a reading.
*
* @param array<int, int> $timestamps
* @return array<int, string>
* Walked from the back rather than taking the last element: Proxmox omits
* the fields for a sample it has no data for, so the newest entry is quite
* often a gap and a tile that showed 0 % because of it would be stating
* something about the host that nobody measured.
*
* @param array<int, ?float> $series
*/
private function clockLabels(array $timestamps): array
private function latest(array $series): ?float
{
return array_map(
fn (int $t) => Carbon::createFromTimestamp($t)->local()->format('H:i'),
$timestamps,
);
}
for ($i = count($series) - 1; $i >= 0; $i--) {
if ($series[$i] !== null) {
return $series[$i];
}
}
/**
* Chart.js config for the load curve.
*
* ONE axis, both series in percent. Two measures on two y-scales is the
* single most misread thing a chart can do, and here it is not even
* tempting: "how full is this host" is the same question for cores and for
* memory, so they belong on the same 0100.
*
* `token:accent` then `token:info` the same fixed order every other chart
* in the console uses, so a series never changes colour between pages.
* Validated as a pair: ΔE 28.3 under protanopia, 39.2 in normal vision.
* Accent falls below 3:1 against the surface, which obliges visible labels
* rather than colour alone the panel carries both current values as
* labelled figures above the canvas, and the legend names both series.
*
* @param array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, available: bool} $load
* @return array<string, mixed>
*/
private function chartConfig(array $load): array
{
$line = fn (string $label, array $data, string $token) => [
'label' => $label,
'data' => $data,
'borderColor' => 'token:'.$token,
'backgroundColor' => 'token:'.$token.'/0.10',
'borderWidth' => 2,
'pointRadius' => 0,
'pointHitRadius' => 12,
'tension' => 0.3,
'fill' => true,
// A gap stays a gap. Chart.js would otherwise draw a straight line
// across a sample Proxmox never recorded — inventing the very
// minutes we know nothing about.
'spanGaps' => false,
];
return [
'type' => 'line',
'data' => [
'labels' => $this->clockLabels($load['labels']),
'datasets' => [
$line(__('hosts.detail.cpu'), $load['cpu'], 'accent'),
$line(__('hosts.detail.ram'), $load['ram'], 'info'),
],
],
'options' => [
// One shared tooltip for the whole minute rather than one per
// line: the question is always "what were both doing then".
'interaction' => ['mode' => 'index', 'intersect' => false],
'plugins' => ['legend' => ['display' => true, 'position' => 'bottom', 'labels' => ['boxWidth' => 8, 'boxHeight' => 8, 'usePointStyle' => true]]],
'scales' => [
'y' => [
// Pinned to 0100 on purpose: an auto-scaled axis makes
// 3 % look like a wall of load.
'min' => 0,
'max' => 100,
'ticks' => ['stepSize' => 25],
'grid' => ['color' => 'token:border'],
],
'x' => ['grid' => ['display' => false], 'ticks' => ['maxTicksLimit' => 6]],
],
],
];
return null;
}
public function render()
@ -217,7 +138,13 @@ class HostDetail extends Component
'instances' => $this->host->instances()->latest('id')->get(),
'health' => $this->host->healthState(),
'load' => $load,
'loadChart' => $this->chartConfig($load),
// Eine Zahl je Kachel, aus derselben Reihe, die die Kachel zeichnet.
'now' => [
'cpu' => $this->latest($load['cpu']),
'ram' => $this->latest($load['ram']),
'netin' => $this->latest($load['netin']),
'netout' => $this->latest($load['netout']),
],
'version' => PveVersion::parse($this->host->pve_version),
]);
}

View File

@ -27,24 +27,34 @@ final class HostLoadSeries
public function __construct(private ProxmoxClient $pve) {}
/** Bytes per second → MiB/s. The tiles carry the unit, so nobody has to guess. */
private const MIB = 1048576;
/**
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, available: bool}
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, available: bool}
*/
public function forHost(Host $host): array
{
// The `v2` is not decoration. A cache entry outlives a deploy, and an
// entry written by the version before the network series has no
// `netin`/`netout` — read as one of today's it kills the host page with
// an undefined key for the length of the TTL, which is exactly the
// minute somebody is looking to see whether the update went through.
// The key moves with the shape; the old entry is simply never read
// again and expires on its own.
return Cache::remember(
'host-load:'.$host->id,
'host-load:v2:'.$host->id,
self::TTL_SECONDS,
fn () => $this->read($host),
);
}
/**
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, available: bool}
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, available: bool}
*/
private function read(Host $host): array
{
$empty = ['labels' => [], 'cpu' => [], 'ram' => [], 'available' => false];
$empty = ['labels' => [], 'cpu' => [], 'ram' => [], 'netin' => [], 'netout' => [], 'available' => false];
try {
$rows = $this->pve->forHost($host)->nodeRrdData($host->node ?? 'pve', 'hour');
@ -62,14 +72,35 @@ final class HostLoadSeries
$labels = [];
$cpu = [];
$ram = [];
$netin = [];
$netout = [];
foreach ($rows as $row) {
$labels[] = (int) ($row['time'] ?? 0);
$cpu[] = $this->percent($row['cpu'] ?? null, 1);
$ram[] = $this->percent($row['memused'] ?? null, $row['memtotal'] ?? null);
$netin[] = $this->rate($row['netin'] ?? null);
$netout[] = $this->rate($row['netout'] ?? null);
}
return ['labels' => $labels, 'cpu' => $cpu, 'ram' => $ram, 'available' => true];
return [
'labels' => $labels,
'cpu' => $cpu,
'ram' => $ram,
'netin' => $netin,
'netout' => $netout,
'available' => true,
];
}
/** Bytes per second as MiB/s — or null, for the same reason percent() returns null. */
private function rate(mixed $bytesPerSecond): ?float
{
if (! is_numeric($bytesPerSecond)) {
return null;
}
return round((float) $bytesPerSecond / self::MIB, 2);
}
/**

View File

@ -79,8 +79,14 @@ return [
'load' => 'Last',
'load_hint' => 'letzte Stunde',
'no_load' => 'Keine Messwerte — der Host antwortet gerade nicht.',
'no_load_short' => 'keine Messwerte',
'cpu' => 'CPU',
'ram' => 'RAM',
'cpu_load' => 'CPU-Auslastung',
'ram_load' => 'RAM-Auslastung',
'net_in' => 'Eingehend',
'net_out' => 'Ausgehend',
'public_ip' => 'Öffentliche IP',
'spec' => 'Ausstattung',
'build' => 'Bau',
'onboarding_done' => 'Übernahme abgeschlossen',
@ -93,6 +99,7 @@ return [
'committed' => ':gb GB belegt',
'reserve' => ':pct % Reserve',
'reserve_label' => 'Reserve',
'reserve_hint' => 'Anteil des Speichers, der frei bleibt.',
'reserve_save' => 'Speichern',
'reserve_saved' => 'Reserve aktualisiert.',
'drain' => 'In Wartung',

View File

@ -77,8 +77,14 @@ return [
'load' => 'Load',
'load_hint' => 'last hour',
'no_load' => 'No measurements — the host is not answering right now.',
'no_load_short' => 'no measurements',
'cpu' => 'CPU',
'ram' => 'RAM',
'cpu_load' => 'CPU usage',
'ram_load' => 'RAM usage',
'net_in' => 'Inbound',
'net_out' => 'Outbound',
'public_ip' => 'Public IP',
'spec' => 'Specification',
'build' => 'Build',
'onboarding_done' => 'Onboarding complete',
@ -93,6 +99,7 @@ return [
'committed' => ':gb GB committed',
'reserve' => ':pct % reserve',
'reserve_label' => 'Reserve',
'reserve_hint' => 'Share of storage kept free.',
'reserve_save' => 'Save',
'reserve_saved' => 'Reserve updated.',
'drain' => 'Maintenance',

View File

@ -1,5 +1,5 @@
@props([
/** @var array<int, int|float> the series, in the order it happened */
/** @var array<int, int|float|null> the series, in the order it happened */
'points' => [],
// muted where the figure is observed, accent where it can be acted on.
// Four accent charts in a row is how an accent stops meaning anything.
@ -7,25 +7,68 @@
'area' => false,
])
@php
$values = array_values(array_filter($points, 'is_numeric'));
$min = $values ? min($values) : 0;
$max = $values ? max($values) : 0;
/*
| A missing sample BREAKS the line; it is not filtered out.
|
| This used to start with array_filter(, 'is_numeric'), which threw the
| gaps away and joined their neighbours. Two things went wrong at once: the
| line claimed a measurement that was never taken, and every point after
| the gap slid left an hour of samples with two gaps drew itself as
| fifty-eight minutes and put the newest reading somewhere in the middle.
|
| The x position is therefore taken from the point's place in the ORIGINAL
| series, and consecutive readings are collected into segments that are
| drawn as separate paths. A series with nothing missing yields exactly one
| segment and the same picture as before which is what every existing
| caller passes.
*/
$values = array_values($points);
$numeric = array_values(array_filter($values, 'is_numeric'));
$min = $numeric ? min($numeric) : 0;
$max = $numeric ? max($numeric) : 0;
$span = max(0.0001, $max - $min);
$step = count($values) > 1 ? 96 / (count($values) - 1) : 96;
$path = '';
$segments = [];
$current = [];
foreach ($values as $i => $v) {
$x = round($i * $step, 1);
$y = round(30 - (($v - $min) / $span) * 26, 1);
$path .= ($i === 0 ? 'M' : ' ').$x.' '.$y;
if (! is_numeric($v)) {
// A single reading on its own cannot be a line; it ends the segment
// without drawing one, rather than emitting a path of one point.
if (count($current) > 1) {
$segments[] = $current;
}
$current = [];
continue;
}
$current[] = [round($i * $step, 1), round(30 - (($v - $min) / $span) * 26, 1)];
}
if (count($current) > 1) {
$segments[] = $current;
}
// Nur die Koordinaten, ohne Befehlsbuchstaben — der Aufrufer setzt das `M`
// oder das `L` davor. Vorher trug diese Funktion ihr `M` selbst, und im
// Flächenpfad stand damit `… L` unmittelbar vor einem `M`: gültig gelesen,
// nicht gezeichnet, und die Füllung verschwand ohne eine einzige Meldung.
$coords = fn (array $seg) => implode(' ', array_map(fn ($p) => $p[0].' '.$p[1], $seg));
@endphp
@if ($path !== '')
@if ($segments !== [])
<svg class="spark {{ $tone === 'accent' ? '' : 'muted' }}" viewBox="0 0 96 34" preserveAspectRatio="none"
style="width:80px;height:32px" aria-hidden="true">
@if ($area)
<path class="area" d="{{ $path }} L96 34 L0 34 Z" />
@endif
<path class="line" d="{{ $path }}" />
@foreach ($segments as $seg)
@if ($area)
{{-- Closed to the baseline under ITS OWN span, not under the whole
box: an area that ran from 0 to 96 would fill the gaps the
line above deliberately leaves open. --}}
<path class="area" d="M{{ $seg[0][0] }} 34 L{{ $coords($seg) }} L{{ end($seg)[0] }} 34 Z" />
@endif
<path class="line" d="M{{ $coords($seg) }}" />
@endforeach
</svg>
@endif

View File

@ -1,7 +1,7 @@
{{-- 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="refreshLoad" @else wire:poll.60s="refreshLoad" @endif>
<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
@ -47,89 +47,85 @@
{{-- 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];
// 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
{{-- Vier Spalten, die Last belegt zwei: eine Zeitreihe braucht Breite. Auf
einem Drittel der Zeile blieben nach Legende und Zeitachse rund dreißig
Pixel Zeichenfläche, und eine Kurve, die man nicht lesen kann, ist
schlimmer als die Zahl, die vorher dort stand. --}}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-4 animate-rise">
{{-- Health --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold text-muted">{{ __('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>
{{-- Ein Kachelraster statt dreier ungleicher Kästen.
{{-- Storage capacity --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs">
<p class="text-xs font-semibold text-muted">{{ __('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>
{{-- Belegung auf eine eigene Zeile über der Eingabe: in der
schmaleren Spalte brach der frühere Einzeiler mitten im
Satz um, und „· Reserve" stand allein unter der Zahl. --}}
<div class="mt-2" x-data="{ r: {{ $host->reserve_pct }} }">
<p class="text-xs text-muted">{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }}</p>
<div class="mt-1.5 flex items-center gap-2">
<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>
</div>
</div>
@else
<p class="mt-2 text-sm text-muted">{{ __('hosts.unknown') }}</p>
@endif
</div>
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.
{{-- Load. Die Karte, die vorher „Rechenleistung" hieß und die
Ausstattung zeigte: 12 Kerne ändern sich nie und sagten nichts
darüber, wie es dem Host geht. --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs lg:col-span-2">
<div class="flex items-baseline justify-between gap-2">
<p class="text-xs font-semibold text-muted">{{ __('hosts.detail.load') }}</p>
<p class="text-xs text-muted">{{ __('hosts.detail.load_hint') }}</p>
</div>
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'])
@php
// Der jüngste Messwert, der wirklich einer ist: die letzten
// Punkte können Lücken sein, und eine Lücke ist keine Null.
$lastOf = fn (array $s) => collect($s)->filter(fn ($v) => $v !== null)->last();
$cpuNow = $lastOf($load['cpu']);
$ramNow = $lastOf($load['ram']);
@endphp
{{-- Beschriftete Zahlen, nicht nur Farbe: der Akzentton liegt
unter 3:1 gegen die Fläche, und die Kurve allein dürfte die
Identität der Reihen deshalb nicht tragen. --}}
<div class="mt-2 flex items-baseline gap-5">
<p class="font-mono text-lg font-semibold text-ink">
{{ $cpuNow !== null ? round($cpuNow).' %' : '—' }}
<span class="ml-1 text-xs font-normal text-muted">{{ __('hosts.detail.cpu') }}</span>
</p>
<p class="font-mono text-lg font-semibold text-ink">
{{ $ramNow !== null ? round($ramNow).' %' : '—' }}
<span class="ml-1 text-xs font-normal text-muted">{{ __('hosts.detail.ram') }}</span>
</p>
</div>
<div class="mt-3 h-36" wire:ignore>
<x-ui.chart :config="$loadChart" update-on="host-load" class="h-36"
:label="__('hosts.detail.load').' — '.__('hosts.detail.load_hint')" />
</div>
@else
<p class="mt-2 text-sm text-muted">{{ __('hosts.detail.no_load') }}</p>
<x-slot:visual><x-ui.spark :points="$load['cpu']" tone="muted" area /></x-slot:visual>
@endif
</div>
</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']" tone="muted" area /></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']" tone="muted" area /></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']" tone="muted" area /></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>
<span class="grid size-[62px] place-items-center rounded-pill {{ $hdisc }}">
<span class="size-3.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
@ -139,6 +135,7 @@
<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-5">
@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')],
@ -163,6 +160,21 @@
@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

View File

@ -323,21 +323,46 @@ it('shows the version number rather than the build hash it is buried in', functi
->assertSee('Proxmox VE 9.2.6');
});
it('draws the load curve from what proxmox recorded', function () {
it('gives every measurement its own tile, so none of them needs a legend', function () {
// Eine Reihe je Kachel: die Beschriftung benennt sie, und damit entfällt
// die Legende, die vorher unter der gemeinsamen Kurve stand.
$s = fakeServices();
$s['pve']->rrd = [
['time' => 1754035200, 'cpu' => 0.25, 'memused' => 34_359_738_368, 'memtotal' => 68_719_476_736],
['time' => 1754035260, 'cpu' => 0.5, 'memused' => 17_179_869_184, 'memtotal' => 68_719_476_736],
['time' => 1754035200, 'cpu' => 0.25, 'memused' => 34_359_738_368, 'memtotal' => 68_719_476_736, 'netin' => 1_048_576, 'netout' => 2_097_152],
['time' => 1754035260, 'cpu' => 0.5, 'memused' => 17_179_869_184, 'memtotal' => 68_719_476_736, 'netin' => 2_097_152, 'netout' => 1_048_576],
];
$host = Host::factory()->active()->create(['node' => 'pve-fns-1']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.load'))
// Die aktuelle Auslastung, nicht die Ausstattung: der letzte Messwert.
->assertSee(__('hosts.detail.cpu_load'))
->assertSee(__('hosts.detail.ram_load'))
->assertSee(__('hosts.detail.net_in'))
->assertSee(__('hosts.detail.net_out'))
// Der jüngste Messwert je Kachel, nicht die Ausstattung.
->assertSee('50')
->assertSee('25');
});
it('shows the public ip on the page, not only in the small print', function () {
// Die Adresse, unter der der Host wirklich erreichbar ist, stand nirgends
// in der Seite — nur klein unter der Überschrift.
fakeServices();
$host = Host::factory()->active()->create(['public_ip' => '49.12.121.79']);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.public_ip'))
->assertSee('49.12.121.79');
});
it('keeps the reserve control with the settings, not on a reading tile', function () {
fakeServices();
$host = Host::factory()->active()->create(['reserve_pct' => 15]);
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.spec'))
->assertSee(__('hosts.detail.reserve_label'));
});
it('says a silent host has no measurements instead of drawing a quiet hour', function () {
// Der ganze Grund, warum HostLoadSeries eine leere Reihe liefert statt
// Nullen: eine Null-Linie behauptet, der Host habe nichts zu tun gehabt.
@ -345,7 +370,9 @@ it('says a silent host has no measurements instead of drawing a quiet hour', fun
$host = Host::factory()->active()->create();
Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])
->assertSee(__('hosts.detail.no_load'));
// Jede Messkachel sagt es für sich, und keine zeigt eine 0.
->assertSee(__('hosts.detail.no_load_short'))
->assertSee(__('hosts.detail.cpu_load'));
});
it('folds the finished onboarding away but keeps it reachable', function () {

View File

@ -13,6 +13,7 @@ use App\Services\Wireguard\FakeWireguardHub;
use App\Services\Wireguard\LocalWireguardHub;
use App\Support\PveVersion;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
@ -191,6 +192,31 @@ it('turns proxmox rrd rows into cpu and ram percentages', function () {
->and($series['labels'])->toHaveCount(2);
});
it('turns the recorded network rates into mebibytes per second', function () {
// Proxmox zählt Bytes je Sekunde. 2 097 152 B/s sind 2 MiB/s — und die
// Einheit steht in der Kachel daneben, damit niemand MB und MiB rät.
Http::fake(['*/rrddata*' => Http::response(['data' => [
['time' => 1754035200, 'netin' => 2_097_152, 'netout' => 524_288],
]])]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
expect($series['netin'])->toBe([2.0])
->and($series['netout'])->toBe([0.5]);
});
it('leaves a missing network sample as a gap too', function () {
Http::fake(['*/rrddata*' => Http::response(['data' => [
['time' => 1754035200, 'netin' => 1_048_576, 'netout' => 1_048_576],
['time' => 1754035260], // Proxmox lässt die Felder weg
]])]);
$series = app(HostLoadSeries::class)->forHost(proxmoxHost());
expect($series['netin'])->toBe([1.0, null])
->and($series['netout'])->toBe([1.0, null]);
});
it('leaves a missing sample as a gap, never as zero', function () {
// Dieselbe Regel, die in instance_metrics begründet steht: eine Messung,
// die scheiterte, muss von einer Messung mit dem Wert null unterscheidbar
@ -218,6 +244,22 @@ it('reports no series at all when the host does not answer', function () {
->and($series['available'])->toBeFalse();
});
it('ignores a cached series written by an older version of itself', function () {
// Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus der Fassung
// vor den Netz-Reihen kennt netin/netout nicht — gelesen wie einer von
// heute, stirbt die Host-Seite 55 Sekunden lang an einem fehlenden
// Schlüssel, und zwar genau in dem Moment, in dem jemand nachsieht, ob das
// Update durch ist.
$host = proxmoxHost();
Cache::put('host-load:'.$host->id, ['labels' => [1], 'cpu' => [1.0], 'ram' => [2.0], 'available' => true], 600);
Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1_048_576, 2_097_152)]])]);
$series = app(HostLoadSeries::class)->forHost($host);
expect($series)->toHaveKeys(['netin', 'netout'])
->and($series['cpu'])->toBe([10.0]); // frisch geholt, nicht der alte Eintrag
});
it('does not ask the host twice while the samples are still fresh', function () {
// Die RRD wird einmal pro Minute fortgeschrieben. Zwei offene Konsolen
// dürfen daraus nicht zwei Anfragen über den Tunnel machen.

View File

@ -0,0 +1,59 @@
<?php
/**
* Eine Lücke in einer Messreihe bleibt eine Lücke auch in der Verlaufslinie.
*
* `HostLoadSeries` hält einen nicht aufgezeichneten Messwert bewusst als `null`
* fest statt als 0, und die Kachel darüber weigert sich, ihn als Zahl zu
* zeigen. Die Linie darunter tat das Gegenteil: sie warf die Lücke heraus und
* verband die Nachbarn. Damit behauptete sie eine Messung, die es nicht gab,
* und schob alles danach nach links eine Stunde sah aus wie 58 Minuten.
*/
it('breaks the line where a sample is missing instead of bridging it', function () {
$view = $this->blade('<x-ui.spark :points="$p" />', ['p' => [10, 20, null, 40, 50]]);
// Zwei Züge: einer vor der Lücke, einer danach. Ein einziges `M` hieße,
// die Linie läuft durch.
expect(substr_count($view->__toString(), '<path'))->toBe(2);
});
it('draws one unbroken line when nothing is missing', function () {
// Die Gegenprobe: an sauberen Reihen — und das sind alle bisherigen
// Aufrufer — darf sich nichts ändern.
$view = $this->blade('<x-ui.spark :points="$p" />', ['p' => [10, 20, 30, 40, 50]]);
expect(substr_count($view->__toString(), '<path'))->toBe(1);
});
it('keeps later points in place when an earlier one is missing', function () {
// Der eigentliche Schaden am Filtern: der letzte Punkt einer Stunde muss
// ganz rechts liegen, egal wie viele Messwerte dazwischen fehlen.
$withGap = $this->blade('<x-ui.spark :points="$p" />', ['p' => [10, null, 30, 40, 50]])->__toString();
$without = $this->blade('<x-ui.spark :points="$p" />', ['p' => [10, 20, 30, 40, 50]])->__toString();
expect($withGap)->toContain('96 ') // rechter Rand erreicht
->and($without)->toContain('96 ');
});
it('closes the filled area with a path a renderer can actually follow', function () {
// Beim Zerlegen in Züge ist der Flächenpfad kurz `… L` direkt vor einem `M`
// geworden — gültig sieht das aus, gezeichnet wird es nicht, und die
// Füllung verschwand still. Ein Blick auf die Seite hat es gezeigt, keine
// Meldung.
$view = $this->blade('<x-ui.spark :points="$p" area />', ['p' => [10, 20, null, 40, 50]])->__toString();
preg_match_all('/class="area" d="([^"]+)"/', $view, $m);
expect($m[1])->toHaveCount(2);
foreach ($m[1] as $d) {
expect($d)->toStartWith('M')
->and($d)->not->toContain('LM') // ein Befehl direkt hinter einem anderen
->and($d)->toEndWith('Z');
}
});
it('draws nothing at all when every sample is missing', function () {
$view = $this->blade('<x-ui.spark :points="$p" />', ['p' => [null, null]]);
expect($view->__toString())->not->toContain('<path');
});