From 589489a9fcad1463ffbe50570ad8796f20551fa7 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 11:02:55 +0200 Subject: [PATCH] =?UTF-8?q?Release=20v1.3.92=20=E2=80=94=20sechs=20Kacheln?= =?UTF-8?q?=20statt=20dreier=20ungleicher=20K=C3=A4sten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 --- VERSION | 2 +- app/Livewire/Admin/HostDetail.php | 115 +++--------- app/Services/Proxmox/HostLoadSeries.php | 41 ++++- lang/de/hosts.php | 7 + lang/en/hosts.php | 7 + resources/views/components/ui/spark.blade.php | 69 +++++-- .../livewire/admin/host-detail.blade.php | 168 ++++++++++-------- tests/Feature/Admin/HostManagementTest.php | 39 +++- tests/Feature/Provisioning/ServicesTest.php | 42 +++++ tests/Feature/SparkGapTest.php | 59 ++++++ 10 files changed, 352 insertions(+), 197 deletions(-) create mode 100644 tests/Feature/SparkGapTest.php diff --git a/VERSION b/VERSION index aa6058d..a24ba92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.91 +1.3.92 diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 17baa86..49ae456 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -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 $timestamps - * @return array + * 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 $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 0–100. - * - * `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, cpu: array, ram: array, available: bool} $load - * @return array - */ - 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 0–100 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), ]); } diff --git a/app/Services/Proxmox/HostLoadSeries.php b/app/Services/Proxmox/HostLoadSeries.php index f151461..3be9b91 100644 --- a/app/Services/Proxmox/HostLoadSeries.php +++ b/app/Services/Proxmox/HostLoadSeries.php @@ -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, cpu: array, ram: array, available: bool} + * @return array{labels: array, cpu: array, ram: array, netin: array, netout: array, 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, cpu: array, ram: array, available: bool} + * @return array{labels: array, cpu: array, ram: array, netin: array, netout: array, 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); } /** diff --git a/lang/de/hosts.php b/lang/de/hosts.php index e1029d0..3435c7c 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -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', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index b2e9c5d..b143500 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -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', diff --git a/resources/views/components/ui/spark.blade.php b/resources/views/components/ui/spark.blade.php index 97246b3..1e5b6ba 100644 --- a/resources/views/components/ui/spark.blade.php +++ b/resources/views/components/ui/spark.blade.php @@ -1,5 +1,5 @@ @props([ - /** @var array the series, in the order it happened */ + /** @var array 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 !== []) @endif diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index e82dbd4..caf05a2 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -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. --}} -
status, ['pending', 'running', 'waiting'])) wire:poll.4s="refreshLoad" @else wire:poll.60s="refreshLoad" @endif> +
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. --}} -
- {{-- Health --}} -
-

{{ __('hosts.detail.health') }}

-

- - {{ __('hosts.health.'.$health) }} -

-

- {{ $host->last_seen_at ? __('hosts.detail.last_seen', ['time' => $host->last_seen_at->diffForHumans()]) : __('hosts.detail.never_seen') }} -

-
+ {{-- Ein Kachelraster statt dreier ungleicher Kästen. - {{-- Storage capacity --}} -
-

{{ __('hosts.detail.storage') }}

- @if ($host->total_gb) -

{{ $host->availableGb() }} / {{ $host->freeGb() }} GB {{ __('hosts.free') }}

-
-
-
- {{-- 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. --}} -
-

{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }}

-
- {{ __('hosts.detail.reserve_label') }} - - % - -
-
- @else -

{{ __('hosts.unknown') }}

- @endif -
+ 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. --}} -
-
-

{{ __('hosts.detail.load') }}

-

{{ __('hosts.detail.load_hint') }}

-
+ 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. --}} +
+ @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 + + @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. --}} -
-

- {{ $cpuNow !== null ? round($cpuNow).' %' : '—' }} - {{ __('hosts.detail.cpu') }} -

-

- {{ $ramNow !== null ? round($ramNow).' %' : '—' }} - {{ __('hosts.detail.ram') }} -

-
-
- -
- @else -

{{ __('hosts.detail.no_load') }}

+ @endif -
+ + + + @if ($load['available']) + + @endif + + + {{-- 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. --}} + + @if ($host->total_gb) + + @endif + + + + @if ($load['available']) + + @endif + + + + @if ($load['available']) + + @endif + + + + + + + + +
{{-- Ausstattung: was feststeht. Vorher standen diese Zahlen in Karten, die @@ -139,6 +135,7 @@

{{ __('hosts.detail.spec') }}

@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
+ + {{-- 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) +
+ {{ __('hosts.detail.reserve_label') }} + + % + + {{ __('hosts.detail.reserve_hint') }} +
+ @endif
{{-- Hosted instances. Die Anzahl steht in der Überschrift, statt als eigene diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index 7cf6f17..43472c1 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -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 () { diff --git a/tests/Feature/Provisioning/ServicesTest.php b/tests/Feature/Provisioning/ServicesTest.php index 1996845..def153c 100644 --- a/tests/Feature/Provisioning/ServicesTest.php +++ b/tests/Feature/Provisioning/ServicesTest.php @@ -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. diff --git a/tests/Feature/SparkGapTest.php b/tests/Feature/SparkGapTest.php new file mode 100644 index 0000000..c813982 --- /dev/null +++ b/tests/Feature/SparkGapTest.php @@ -0,0 +1,59 @@ +blade('', ['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(), '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('', ['p' => [10, 20, 30, 40, 50]]); + + expect(substr_count($view->__toString(), '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('', ['p' => [10, null, 30, 40, 50]])->__toString(); + $without = $this->blade('', ['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('', ['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('', ['p' => [null, null]]); + + expect($view->__toString())->not->toContain('