From 665a0c4e08e489b4a25897c89736468e7168c001 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 10:24:21 +0200 Subject: [PATCH] =?UTF-8?q?Release=20v1.3.91=20=E2=80=94=20die=20Host-Seit?= =?UTF-8?q?e=20zeigt=20Last=20statt=20Ausstattung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Karte "Rechenleistung" zeigte keine Leistung. 12 Kerne und 63 GB sind die Ausstattung des Blechs und ändern sich nie — sie standen aber im selben Kartenraster wie "Zustand" und "Speicher", die beide leben. Wer die Seite öffnete, um zu sehen, wie es dem Host geht, las dort eine Zahl, die das nie sagen konnte. An ihrer Stelle steht jetzt die Last: CPU und RAM als Stundenkurve, beide in Prozent auf EINER Achse, dazu die aktuellen Werte als beschriftete Zahlen. Die Geschichte kommt aus Proxmox' eigener Aufzeichnung (/nodes/{node}/rrddata), nicht aus einem eigenen Sampler. Ein Sampler hieße neue Tabelle, minütlicher Job, Aufräum-Job und ~1440 Zeilen je Host und Tag — um weniger genau nachzubauen, was ohnehin auf der Platte liegt. Die RRD ist ab der ersten Sekunde gefüllt, auch für die Stunde vor dem ersten Hinsehen, und kann nicht von dem abweichen, was Proxmox' eigene Oberfläche zeigt. Eine Lücke bleibt eine Lücke: ein Punkt ohne Messwert wird null, nie 0, und spanGaps steht auf false. Dieselbe Regel wie in instance_metrics. Antwortet der Host gar nicht, sagt die Tafel das in einem Satz, statt eine ruhige Stunde zu zeichnen — auf dem Testhost live bestätigt. Der Fehler, den das ans Licht gebracht hat ------------------------------------------ x-ui.chart steht überall unter wire:ignore, sonst zerstört Livewire das Canvas. Ein Poll erreicht den Chart also nie. Dafür bekam das Bauteil ein optionales update-on: es hört auf ein Fenster-Ereignis und tauscht die Daten IM bestehenden Chart.js-Objekt. Das lief nicht. Die Zahlen neben der Kurve wanderten, die Kurve nicht, und chart.update() starb still im Legenden-Plugin: TypeError: Cannot set properties of undefined (setting 'fullSize') Grund: die Chart.js-Instanz lag als Eigenschaft im Alpine-Objekt und wurde damit reaktiv umhüllt. Chart.js' Plugin-Innenleben überlebt das Proxy nicht. Gemessen statt geschlossen: dieselbe Instanz wirft über das Proxy und läuft über Alpine.raw(). Sie liegt jetzt in der Closure. Aufgefallen ist es nie, weil bis zum ersten Live-Chart kein einziger Chart in diesem Repo je update() gerufen hat — konstruieren und Erstzeichnen gehen durch die Hülle noch. tests/Feature/ChartLiveUpdateTest.php hält die Regel fest, damit der nächste Live-Chart nicht denselben Nachmittag kostet. Der Rest -------- - Version lesbar: "Proxmox VE 9.2.6" statt pve-manager/9.2.6/7f8d…, mitten in der Bau-Kennung abgeschnitten. Die Kennung steht klein darunter. Eine unerwartete Form wird unverändert durchgereicht statt verschluckt. - Vier Kleinkarten (Mgmt-IP, Node, Version, Instanzen) sind eine Ausstattungs-Tafel geworden. Die Instanzen-Anzahl steht in der Überschrift der Liste, die sie ohnehin zeigt. - Der Übernahme-Fortschritt klappt zu, sobald sie durch ist. Fünfzehn abgehakte Schritte sind auf einem laufenden Host kein Dauerinhalt — aufklappbar über
, ohne JavaScript. - PlanVersion::requiredTemplateVmids() ersetzt die dritte Kopie derselben Fensterlogik. - BuildVmTemplate sagt nicht mehr "this takes 10–20 minutes". Das war eine Schätzung vor dem ersten Lauf; gemessen waren es unter zwei. Eine Konsole, die falsch vorhersagt, erzieht dazu, sie zu ignorieren. Geprüft: 2281 Tests grün, Pint sauber, Codex ohne Befund. Die Farbwahl gegen den Validator gerechnet (ΔE 28,3 protan / 39,2 normal; der Akzent liegt unter 3:1 gegen die Fläche, deshalb tragen beide Reihen sichtbare Beschriftung). Im Browser: null Konsolenfehler über einen vollen Poll-Zyklus, und die Kurve wandert auf dem echten Poll ohne Neuladen — mit eingespielten Messwerten belegt, weil die Testhosts in TEST-NET liegen und nie antworten. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Livewire/Admin/HostDetail.php | 107 ++++++++++++ .../Steps/Host/BuildVmTemplate.php | 5 +- app/Services/Proxmox/FakeProxmoxClient.php | 13 ++ app/Services/Proxmox/HostLoadSeries.php | 92 ++++++++++ app/Services/Proxmox/HttpProxmoxClient.php | 10 ++ app/Services/Proxmox/ProxmoxClient.php | 11 ++ app/Support/PveVersion.php | 40 +++++ ...2026-08-01-host-detailseite-last-design.md | 98 +++++++++++ lang/de/hosts.php | 8 + lang/en/hosts.php | 8 + resources/js/app.js | 58 ++++++- resources/views/components/ui/chart.blade.php | 11 +- .../livewire/admin/host-detail.blade.php | 159 +++++++++++++----- tests/Feature/Admin/HostManagementTest.php | 60 +++++++ tests/Feature/ChartLiveUpdateTest.php | 51 ++++++ tests/Feature/Provisioning/ServicesTest.php | 92 ++++++++++ 17 files changed, 779 insertions(+), 46 deletions(-) create mode 100644 app/Services/Proxmox/HostLoadSeries.php create mode 100644 app/Support/PveVersion.php create mode 100644 docs/superpowers/specs/2026-08-01-host-detailseite-last-design.md create mode 100644 tests/Feature/ChartLiveUpdateTest.php diff --git a/VERSION b/VERSION index 619d9ea..aa6058d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.90 +1.3.91 diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 6cd0c56..17baa86 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -5,6 +5,9 @@ namespace App\Livewire\Admin; use App\Models\Host; 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; @@ -27,6 +30,26 @@ 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 { @@ -97,6 +120,85 @@ class HostDetail extends Component return $steps; } + /** + * Unix seconds → the wall clock the operator is reading it on (R19). + * + * @param array $timestamps + * @return array + */ + private function clockLabels(array $timestamps): array + { + return array_map( + fn (int $t) => Carbon::createFromTimestamp($t)->local()->format('H:i'), + $timestamps, + ); + } + + /** + * 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]], + ], + ], + ]; + } + public function render() { $run = $this->currentRun(); @@ -106,12 +208,17 @@ class HostDetail extends Component ? $run->events()->latest('id')->limit(30)->get() : collect(); + $load = app(HostLoadSeries::class)->forHost($this->host); + return view('livewire.admin.host-detail', [ 'run' => $run, 'steps' => $this->buildSteps($run), 'events' => $events, 'instances' => $this->host->instances()->latest('id')->get(), 'health' => $this->host->healthState(), + 'load' => $load, + 'loadChart' => $this->chartConfig($load), + 'version' => PveVersion::parse($this->host->pve_version), ]); } } diff --git a/app/Provisioning/Steps/Host/BuildVmTemplate.php b/app/Provisioning/Steps/Host/BuildVmTemplate.php index 30ec1a5..14e5042 100644 --- a/app/Provisioning/Steps/Host/BuildVmTemplate.php +++ b/app/Provisioning/Steps/Host/BuildVmTemplate.php @@ -232,7 +232,10 @@ class BuildVmTemplate extends HostStep 'i=0; while [ "$i" -lt 15 ] && [ ! -s "$W/pid" ]; do sleep 1; i=$((i + 1)); done', ])); - return StepResult::poll(30, 'building the VM template (this takes 10–20 minutes)'); + // No time estimate here any more. "10–20 minutes" was a guess written + // before this had ever run; the first real build took under two, and a + // console that predicts wrongly teaches an operator to ignore it. + return StepResult::poll(30, 'building the VM template'); } /** @param array{state:string, alive:string, phase:string, note:string} $status */ diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php index c88042a..d3c2e04 100644 --- a/app/Services/Proxmox/FakeProxmoxClient.php +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -192,6 +192,19 @@ class FakeProxmoxClient implements ProxmoxClient $this->networkRates[$vmid] = $mbytesPerSecond; } + /** + * The node's recorded history, as PVE would hand it back. Empty by default: + * a host nobody scripted samples for has none. + * + * @var array> + */ + public array $rrd = []; + + public function nodeRrdData(string $node, string $timeframe = 'hour'): array + { + return $this->rrd; + } + public function vmExists(string $node, int $vmid): bool { return in_array($vmid, $this->clonedVmids, true) || in_array($vmid, $this->runningVmids, true); diff --git a/app/Services/Proxmox/HostLoadSeries.php b/app/Services/Proxmox/HostLoadSeries.php new file mode 100644 index 0000000..f151461 --- /dev/null +++ b/app/Services/Proxmox/HostLoadSeries.php @@ -0,0 +1,92 @@ +, cpu: array, ram: array, available: bool} + */ + public function forHost(Host $host): array + { + return Cache::remember( + 'host-load:'.$host->id, + self::TTL_SECONDS, + fn () => $this->read($host), + ); + } + + /** + * @return array{labels: array, cpu: array, ram: array, available: bool} + */ + private function read(Host $host): array + { + $empty = ['labels' => [], 'cpu' => [], 'ram' => [], 'available' => false]; + + try { + $rows = $this->pve->forHost($host)->nodeRrdData($host->node ?? 'pve', 'hour'); + } catch (Throwable) { + // Deliberately not a series of zeros. A flat line at zero is a + // claim about the host — that it was idle — and this is the one + // case where we know nothing at all. The panel says so instead. + return $empty; + } + + if ($rows === []) { + return $empty; + } + + $labels = []; + $cpu = []; + $ram = []; + + 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); + } + + return ['labels' => $labels, 'cpu' => $cpu, 'ram' => $ram, 'available' => true]; + } + + /** + * A share of a whole, in percent — or null when either half is missing. + * + * Null rather than 0.0, and that is the whole care taken here: Proxmox + * simply omits the fields for a sample it has no data for, and charting + * that omission as zero draws a quiet hour the host never had. The same + * distinction `instance_metrics` makes for its nullable columns, for the + * same reason. + */ + private function percent(mixed $part, mixed $whole): ?float + { + if (! is_numeric($part) || ! is_numeric($whole) || (float) $whole <= 0.0) { + return null; + } + + return round((float) $part / (float) $whole * 100, 2); + } +} diff --git a/app/Services/Proxmox/HttpProxmoxClient.php b/app/Services/Proxmox/HttpProxmoxClient.php index feac499..fa0d952 100644 --- a/app/Services/Proxmox/HttpProxmoxClient.php +++ b/app/Services/Proxmox/HttpProxmoxClient.php @@ -114,6 +114,16 @@ class HttpProxmoxClient implements ProxmoxClient ->throw()->json('data'); } + public function nodeRrdData(string $node, string $timeframe = 'hour'): array + { + // AVERAGE, not MAX: MAX turns a single busy second into a plateau that + // reads like sustained load. The console's question is "how busy is + // this host", and the average is the honest answer to it. + return $this->http() + ->get("/nodes/{$node}/rrddata", ['timeframe' => $timeframe, 'cf' => 'AVERAGE']) + ->throw()->json('data', []); + } + public function vmStatus(string $node, int $vmid): array { return $this->http()->get("/nodes/{$node}/qemu/{$vmid}/status/current")->throw()->json('data', []); diff --git a/app/Services/Proxmox/ProxmoxClient.php b/app/Services/Proxmox/ProxmoxClient.php index cdcf598..7da0a79 100644 --- a/app/Services/Proxmox/ProxmoxClient.php +++ b/app/Services/Proxmox/ProxmoxClient.php @@ -66,6 +66,17 @@ interface ProxmoxClient */ public function shutdownVm(string $node, int $vmid, int $timeoutSeconds): string; + /** + * Proxmox's own recorded history for a node — cpu, memused, memtotal, … per + * sample, oldest first. + * + * $timeframe is one of hour|day|week|month|year; PVE keeps all five, so a + * chart never has to be fed from a sampler of ours. + * + * @return array> + */ + public function nodeRrdData(string $node, string $timeframe = 'hour'): array; + /** @return array ['status' => 'running'|'stopped', …] */ public function vmStatus(string $node, int $vmid): array; diff --git a/app/Support/PveVersion.php b/app/Support/PveVersion.php new file mode 100644 index 0000000..6b441a9 --- /dev/null +++ b/app/Support/PveVersion.php @@ -0,0 +1,40 @@ + null, 'build' => null]; + } + + // pve-manager//. The build is optional — some hosts + // report only the first two segments. + if (preg_match('#^[a-z-]+/([0-9][0-9.]*)(?:/([0-9a-f]+))?$#i', $raw, $m) === 1) { + return ['version' => $m[1], 'build' => $m[2] ?? null]; + } + + // An unexpected shape is passed through whole rather than swallowed. A + // display that shows nothing when it does not recognise something is + // worse than one that hands over what it was given: the operator can + // still read it, and nobody is left wondering whether the field is + // empty or the parser gave up. + return ['version' => $raw, 'build' => null]; + } +} diff --git a/docs/superpowers/specs/2026-08-01-host-detailseite-last-design.md b/docs/superpowers/specs/2026-08-01-host-detailseite-last-design.md new file mode 100644 index 0000000..52b46c3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-host-detailseite-last-design.md @@ -0,0 +1,98 @@ +# Host-Detailseite: Last statt Ausstattung + +*Entwurf, 1. August 2026* + +## Der Befund + +Die Karte **„Rechenleistung"** zeigt keine Leistung. `12 Kerne / 63 GB` ist die +Ausstattung des Blechs und ändert sich nie — sie steht aber im selben +Kartenraster wie „Zustand" und „Speicher", die beide leben. Wer die Seite +öffnet, um zu sehen, wie es dem Host geht, liest dort eine Zahl, die nie etwas +darüber sagt. + +Zwei kleinere Dinge derselben Art: die Version steht als +`pve-manager/9.2.6/7f8d010005bd72…` und wird mitten in der Bau-Kennung +abgeschnitten — sichtbar ist alles außer der Zahl, um die es geht. Und der +Übernahme-Fortschritt hält auf einem seit Wochen laufenden Host fünfzehn +abgehakte Schritte in voller Höhe offen. + +## 1. Woher die Kurve kommt + +`GET /nodes/{node}/rrddata?timeframe=hour&cf=AVERAGE`. + +Proxmox zeichnet CPU, Speicher, Netz und I/O-Wait ohnehin auf — in Stunde, Tag, +Woche, Monat und Jahr. Ein eigener Sampler hieße: neue Tabelle, minütlicher Job, +Aufräum-Job, ~1440 Zeilen je Host und Tag, und eine Kurve, die am Anfang leer +ist. Die RRD ist ab der ersten Sekunde gefüllt, auch für die Stunde, bevor +jemand hingesehen hat, und es sind dieselben Zahlen wie in Proxmox' eigener +Oberfläche — eine Abweichung zwischen beiden kann nicht entstehen. + +Der Preis ist bekannt und wird getragen: ist der Host nicht erreichbar, gibt es +keine Kurve. Das ist ehrlicher als eine aus unserer Datenbank nachgezeichnete. + +**`App\Services\Proxmox\HostLoadSeries`** rechnet die Antwort in zwei +Prozentreihen um: `cpu * 100` und `memused / memtotal * 100`. + +**Eine Lücke bleibt eine Lücke.** Ein Punkt ohne Messwert wird `null`, nie `0` — +dieselbe Regel, die in `instance_metrics` schon begründet steht: „eine Messung, +die scheiterte, muss von einer Messung mit dem Wert null unterscheidbar sein." +Eine Null-Linie behauptet einen ruhigen Host; eine Lücke behauptet nichts. +Antwortet der Host gar nicht, ist die Reihe leer und die Tafel sagt das in einem +Satz. + +**Abgefragt wird im Minutentakt**, weil die RRD selbst im Minutentakt +fortgeschrieben wird. Alles darunter wären Anfragen über den Tunnel für dieselbe +Antwort. Dazu `Cache::remember` für 55 s je Host, damit zwei offene Konsolen den +Host nicht doppelt fragen. + +## 2. Die Kurve wirklich lebendig + +Die eigentliche Hürde: `x-ui.chart` steht überall unter `wire:ignore` — sonst +zerstört Livewire bei jedem Rendern das Canvas — und die Alpine-Komponente baut +den Chart **einmal** in `init()`. Einen Aktualisierungsweg gibt es nicht, also +würde ein Poll die Zahlen daneben erneuern und die Kurve stehenlassen. + +`x-ui.chart` bekommt deshalb ein optionales **`update-on=""`**. Ist es +gesetzt, hört die Komponente auf dieses Fenster-Ereignis, tauscht Labels und +Datenreihen **im bestehenden Chart.js-Objekt** aus und ruft `update('none')` — +ohne Animation, sonst zuckt die Kurve jede Minute neu auf. Livewire schickt nach +jedem Poll `$this->dispatch('host-load', …)`. + +Eine Ergänzung am Designsystem, die jeder künftige Live-Chart erbt, statt einer +Sonderlösung auf dieser einen Seite. + +## 3. Die Seite nach lebendig und fest + +Heute sehen beide gleich aus, und deshalb wirkt eine Ausstattungszahl wie eine +Messung. + +| | heute | danach | +|---|---|---| +| **Lebendig** | Zustand, Speicher, „Rechenleistung" | Zustand, **Last** (CPU + RAM, Stundenkurve), Speicher | +| **Fest** | vier Kleinkarten: Mgmt-IP, Node, Version, Instanzen | **eine Ausstattungs-Tafel**: Kerne, RAM, Node, Mgmt-IP, Version | +| **Instanzen** | eigene Kleinkarte mit „0" | als Anzahl in die Überschrift von „Gehostete Instanzen", wo die Liste ohnehin steht | +| **Version** | abgeschnitten | **Proxmox VE 9.2.6**, Bau-Kennung klein darunter | +| **Fortschritt** | 15 abgehakte Schritte, dauerhaft | während der Übernahme offen; ist sie durch, eine Zeile zum Aufklappen (`
`, kein JS) | + +Die Versionszeichenkette wird von `App\Support\PveVersion` zerlegt +(`pve-manager/9.2.6/7f8d…` → `9.2.6` + `7f8d…`). Passt die Form nicht, wird die +Zeichenkette unverändert gezeigt — eine Anzeige, die an einer unerwarteten Form +lieber nichts zeigt, ist schlechter als eine, die das Rohe durchreicht. + +## Prüfung + +- `HostLoadSeries`: Prozentrechnung aus echten RRD-Zeilen; ein Punkt ohne + `memtotal` wird `null` und nicht `0`; leere Antwort und geworfene Ausnahme + ergeben beide eine leere Reihe, nie Nullen; der Zwischenspeicher fragt den + Host nicht zweimal. +- `PveVersion`: die erwartete Form, eine unerwartete, eine leere. +- Seite: Last-Tafel vorhanden, „Proxmox VE 9.2.6" lesbar, Stepper offen während + des Laufs und zugeklappt danach, Instanzen-Anzahl in der Überschrift. +- R12: null Konsolenfehler auf `/admin/hosts/{uuid}`. + +## Was hier nicht passiert + +- **Kein Flottenbild über alle Hosts.** Die RRD-Wahl schließt es nicht aus, aber + es ist eine eigene Aufgabe mit einer eigenen Frage dahinter. +- **Kein Alarm bei Dauerlast.** Überwachung liegt bei Uptime Kuma; eine zweite + Stelle, die Schwellen kennt, wäre eine zweite Wahrheit. diff --git a/lang/de/hosts.php b/lang/de/hosts.php index 6fd850a..e1029d0 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -76,6 +76,14 @@ return [ 'health' => 'Zustand', 'storage' => 'Speicher', 'compute' => 'Rechenleistung', + 'load' => 'Last', + 'load_hint' => 'letzte Stunde', + 'no_load' => 'Keine Messwerte — der Host antwortet gerade nicht.', + 'cpu' => 'CPU', + 'ram' => 'RAM', + 'spec' => 'Ausstattung', + 'build' => 'Bau', + 'onboarding_done' => 'Übernahme abgeschlossen', 'node' => 'Node', 'instances' => 'Instanzen', 'hosted' => 'Gehostete Instanzen', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index dd500c4..b2e9c5d 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -74,6 +74,14 @@ return [ 'detail' => [ 'health' => 'Health', + 'load' => 'Load', + 'load_hint' => 'last hour', + 'no_load' => 'No measurements — the host is not answering right now.', + 'cpu' => 'CPU', + 'ram' => 'RAM', + 'spec' => 'Specification', + 'build' => 'Build', + 'onboarding_done' => 'Onboarding complete', 'storage' => 'Storage', 'compute' => 'Compute', 'node' => 'Node', diff --git a/resources/js/app.js b/resources/js/app.js index ad65d72..5035043 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -523,8 +523,23 @@ document.addEventListener('alpine:init', () => { })); // ── Chart.js island ────────────────────────────────────────────────── - window.Alpine.data('chart', (config) => ({ - instance: null, + // Die Chart.js-Instanz liegt BEWUSST in der Closure und nicht als + // Eigenschaft des zurückgegebenen Objekts. + // + // Alles, was Alpine.data() zurückgibt, wird reaktiv — also in ein Proxy + // gehüllt. Chart.js' Plugin-Innenleben überlebt das nicht: `chart.update()` + // stirbt in der Legende mit "Cannot set properties of undefined (setting + // 'fullSize')". Konstruieren und Erstzeichnen gehen durch die Hülle noch, + // deshalb ist es jahrelang nicht aufgefallen — bis zum Live-Chart hat kein + // Chart in diesem Repo je update() gerufen. + // + // Gemessen, nicht vermutet: dieselbe Instanz wirft über das Proxy und läuft + // über Alpine.raw(). Erzwungen durch tests/Feature/ChartLiveUpdateTest.php. + window.Alpine.data('chart', (config, updateOn = null) => { + let instance = null; + let onFresh = null; + + return { init() { // Read tokens from this element so charts honour a scoped theme // (e.g. .theme-admin dark tokens), not just :root. @@ -541,12 +556,45 @@ document.addEventListener('alpine:init', () => { animation: prefersReducedMotion() ? false : cfg.options?.animation, ...cfg.options, }; - this.instance = new Chart(this.$refs.canvas, cfg); + instance = new Chart(this.$refs.canvas, cfg); + + // Live charts. A chart under wire:ignore never sees a re-render, so + // without this a polling page updates every figure around the + // canvas and leaves the curve where it was an hour ago. + if (updateOn) { + onFresh = (e) => this.apply(e.detail); + window.addEventListener(updateOn, onFresh); + } }, + + // Swap the data inside the live instance rather than rebuilding it: + // rebuilding would drop the hovered tooltip and re-run the entry + // animation on every tick. + apply(detail) { + const fresh = Array.isArray(detail) ? detail[0] : detail; + if (!instance || !fresh) return; + + if (fresh.labels) instance.data.labels = fresh.labels; + + // Positional: dataset 0 stays dataset 0, so a series keeps its + // colour across updates. Colour follows the entity, never its + // position in whatever came back. + (fresh.datasets || []).forEach((points, i) => { + if (instance.data.datasets[i]) instance.data.datasets[i].data = points; + }); + + // 'none' — no animation. Re-animating every minute makes a calm + // host look like a busy one out of the corner of an eye. + instance.update('none'); + }, + destroy() { - this.instance?.destroy(); + if (onFresh && updateOn) window.removeEventListener(updateOn, onFresh); + instance?.destroy(); + instance = null; }, - })); + }; + }); // ── Connection banner ────────────────────────────────────────────────── // "Keine Verbindung" while offline, "Verbindung wiederhergestellt" for a diff --git a/resources/views/components/ui/chart.blade.php b/resources/views/components/ui/chart.blade.php index 7141946..55229a6 100644 --- a/resources/views/components/ui/chart.blade.php +++ b/resources/views/components/ui/chart.blade.php @@ -3,9 +3,18 @@ // "token:accent/0.12" strings, resolved from CSS design tokens at runtime. 'config', 'label' => null, + // Optional: the name of a browser event carrying fresh data for this chart. + // + // Charts sit under wire:ignore — otherwise Livewire destroys the canvas on + // every render — so no amount of polling reaches them, and a live page + // would refresh the figures beside the chart while the curve stood still. + // With this set, the component swaps labels and datasets INSIDE the + // existing Chart.js instance instead. Livewire feeds it with + // $this->dispatch('', labels: [...], datasets: [[...], [...]]). + 'updateOn' => null, ])
merge(['class' => 'relative']) }} > diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index f393627..e82dbd4 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -1,4 +1,7 @@ -
status, ['pending', 'running', 'waiting'])) wire:poll.4s @endif> +{{-- 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> @php $badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info'; @endphp @@ -47,7 +50,11 @@ $htone = ['online' => 'text-success', 'stale' => 'text-warning', 'offline' => 'text-danger'][$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') }}

@@ -68,52 +75,103 @@
-
- {{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }} · {{ __('hosts.detail.reserve_label') }} - - % - + {{-- 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
- {{-- Compute --}} -
-

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

-
-
-

{{ $host->cpu_cores ?? '—' }}

-

{{ __('hosts.meta.cores') }}

-
-
-

{{ $host->total_ram_mb ? round($host->total_ram_mb / 1024).' GB' : '—' }}

-

{{ __('hosts.meta.ram') }}

-
+ {{-- 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') }}

+ @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
- {{-- Technical facts --}} -
- @foreach ([ - ['meta.wg_ip', $host->wg_ip ?? __('hosts.unknown')], - ['detail.node', $host->node ?? __('hosts.unknown')], - ['meta.version', $host->pve_version ?? __('hosts.unknown')], - ['detail.instances', (string) $instances->count()], - ] as [$key, $value]) -
-

{{ __('hosts.'.$key) }}

-

{{ $value }}

+ {{-- Ausstattung: was feststeht. Vorher standen diese Zahlen in Karten, die + wie die lebendigen aussahen — deshalb las sich eine Kernzahl wie eine + Messung. --}} +
+

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

+
+ @foreach ([ + [__('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]) +
+
{{ $label }}
+
{{ $value }}
+
+ @endforeach +
+
{{ __('hosts.meta.version') }}
+ @if ($version['version']) + {{-- Die Zahl, die jemand sucht, statt der Bau-Kennung, in der + sie vorher abgeschnitten wurde. --}} +
Proxmox VE {{ $version['version'] }}
+ @if ($version['build']) +
{{ __('hosts.detail.build') }} {{ $version['build'] }}
+ @endif + @else +
{{ __('hosts.unknown') }}
+ @endif
- @endforeach +
- {{-- Hosted instances --}} + {{-- Hosted instances. Die Anzahl steht in der Überschrift, statt als eigene + Kleinkarte neben der Liste, die sie ohnehin zeigt. --}}
-

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

+

+ {{ __('hosts.detail.hosted') }} + {{ $instances->count() }} +

@if ($instances->isEmpty())

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

@else @@ -145,13 +203,38 @@
@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
-

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

- @if ($run) - + @if ($onboardingDone) + {{--
statt Alpine: ein Aufklapper braucht kein + JavaScript, und der Inhalt bleibt für die Suche im Browser + und für Screenreader erreichbar. --}} +
+ + + {{ __('hosts.detail.onboarding_done') }} + + {{ $run->updated_at?->local()->isoFormat('D. MMM, HH:mm') }} + + +
+ +
+
@else -

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

+

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

+ @if ($run) + + @else +

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

+ @endif @endif
diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index c4aba18..7cf6f17 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -309,3 +309,63 @@ it('treats a drained host as running, not as one waiting for takeover', function expect(HostEnrolment::resolve($alt)?->id)->toBe($host->id) ->and(ReissueTakeover::eligible($host))->toBeFalse(); }); + +// --- Detailseite: lebendig und fest getrennt --------------------------------- + +it('shows the version number rather than the build hash it is buried in', function () { + // In der schmalen Karte wurde `pve-manager/9.2.6/7f8d…` mitten in der + // Bau-Kennung abgeschnitten — sichtbar war alles außer der Zahl, um die es + // geht. + fakeServices(); + $host = Host::factory()->active()->create(['pve_version' => 'pve-manager/9.2.6/7f8d010005bd7231']); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) + ->assertSee('Proxmox VE 9.2.6'); +}); + +it('draws the load curve from what proxmox recorded', function () { + $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], + ]; + $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('50') + ->assertSee('25'); +}); + +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. + fakeServices(); // rrd bleibt leer + $host = Host::factory()->active()->create(); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) + ->assertSee(__('hosts.detail.no_load')); +}); + +it('folds the finished onboarding away but keeps it reachable', function () { + fakeServices(); + $host = Host::factory()->active()->create(); + ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_COMPLETED]); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) + // Fünfzehn abgehakte Schritte sind auf einem laufenden Host kein + // Dauerinhalt — aufklappbar, nicht weg. + ->assertSee('assertSee(__('hosts.detail.onboarding_done')); +}); + +it('keeps the stepper open while the onboarding is still running', function () { + fakeServices(); + $host = Host::factory()->create(['status' => 'onboarding']); + ProvisioningRun::factory()->forHost($host)->create(['status' => ProvisioningRun::STATUS_RUNNING]); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) + ->assertDontSee('assertSee(__('hosts.progress')); +}); diff --git a/tests/Feature/ChartLiveUpdateTest.php b/tests/Feature/ChartLiveUpdateTest.php new file mode 100644 index 0000000..e907089 --- /dev/null +++ b/tests/Feature/ChartLiveUpdateTest.php @@ -0,0 +1,51 @@ +not->toBeFalse('Die Alpine-Komponente `chart` gibt es nicht mehr — dieser Test braucht einen neuen Anker.'); + + // Der Rumpf bis zur nächsten Alpine-Komponente. + $next = strpos($js, 'Alpine.data(', $start + 20); + $body = substr($js, $start, $next === false ? 4000 : $next - $start); + + expect($body) + // In der Closure, nicht am Objekt. + ->toContain('let instance = null') + // `instance: null` als Objekt-Eigenschaft ist genau der Rückfall. + ->not->toMatch('/^\s*instance:\s/m') + ->not->toContain('this.instance'); +}); + +it('still offers the live update path the host page depends on', function () { + $js = file_get_contents(base_path('resources/js/app.js')); + $blade = file_get_contents(base_path('resources/views/components/ui/chart.blade.php')); + + // Ohne diesen Weg zeigt die Lastkurve für immer die Werte des Seitenaufrufs. + expect($js)->toContain('window.addEventListener(updateOn') + ->and($js)->toContain("instance.update('none')") + ->and($blade)->toContain('updateOn'); +}); diff --git a/tests/Feature/Provisioning/ServicesTest.php b/tests/Feature/Provisioning/ServicesTest.php index 2132f88..1996845 100644 --- a/tests/Feature/Provisioning/ServicesTest.php +++ b/tests/Feature/Provisioning/ServicesTest.php @@ -5,11 +5,13 @@ use App\Provisioning\Jobs\RemoveWireguardPeer; use App\Services\Dns\FileHostDnsDirectory; use App\Services\Dns\HttpHetznerDnsClient; use App\Services\Proxmox\FakeProxmoxClient; +use App\Services\Proxmox\HostLoadSeries; use App\Services\Proxmox\HttpProxmoxClient; use App\Services\Ssh\CommandResult; use App\Services\Ssh\FakeRemoteShell; use App\Services\Wireguard\FakeWireguardHub; use App\Services\Wireguard\LocalWireguardHub; +use App\Support\PveVersion; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; @@ -160,6 +162,96 @@ it('reports an ordinary virtual machine carrying the vmid as no template', funct expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeFalse(); }); +// --- HostLoadSeries --------------------------------------------------------- +// +// Die Stundenkurve auf der Host-Seite. Proxmox zeichnet CPU und Speicher +// ohnehin auf; hier wird daraus nur gerechnet — und die eine Regel eingehalten, +// die eine Messreihe ehrlich hält: eine Lücke ist keine Null. + +function rrdRow(int $time, ?float $cpu, ?int $memused, ?int $memtotal): array +{ + return array_filter([ + 'time' => $time, + 'cpu' => $cpu, + 'memused' => $memused, + 'memtotal' => $memtotal, + ], fn ($v) => $v !== null); +} + +it('turns proxmox rrd rows into cpu and ram percentages', function () { + Http::fake(['*/rrddata*' => Http::response(['data' => [ + rrdRow(1754035200, 0.0125, 8_589_934_592, 68_719_476_736), // 1.25 % CPU, 12.5 % RAM + rrdRow(1754035260, 0.5, 34_359_738_368, 68_719_476_736), // 50 % CPU, 50 % RAM + ]])]); + + $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + + expect($series['cpu'])->toBe([1.25, 50.0]) + ->and($series['ram'])->toBe([12.5, 50.0]) + ->and($series['labels'])->toHaveCount(2); +}); + +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 + // sein. Eine Null-Linie behauptet einen ruhigen Host. + Http::fake(['*/rrddata*' => Http::response(['data' => [ + rrdRow(1754035200, 0.25, 34_359_738_368, 68_719_476_736), + rrdRow(1754035260, null, null, null), // Proxmox lässt die Felder weg + ]])]); + + $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + + expect($series['cpu'])->toBe([25.0, null]) + ->and($series['ram'])->toBe([50.0, null]); +}); + +it('reports no series at all when the host does not answer', function () { + // Nicht eine Reihe aus Nullen: der Host sagt nichts, und die Tafel muss das + // sagen dürfen statt eine ruhige Stunde zu zeichnen. + Http::fake(['*' => Http::response('gateway timeout', 502)]); + + $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + + expect($series['cpu'])->toBe([]) + ->and($series['ram'])->toBe([]) + ->and($series['available'])->toBeFalse(); +}); + +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. + Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1, 2)]])]); + $host = proxmoxHost(); + + app(HostLoadSeries::class)->forHost($host); + app(HostLoadSeries::class)->forHost($host); + + Http::assertSentCount(1); +}); + +// --- PveVersion ------------------------------------------------------------- + +it('reads the version out of what proxmox calls itself', function () { + $v = PveVersion::parse('pve-manager/9.2.6/7f8d010005bd7231'); + + expect($v['version'])->toBe('9.2.6') + ->and($v['build'])->toBe('7f8d010005bd7231'); +}); + +it('shows an unexpected version string unchanged rather than nothing', function () { + // Eine Anzeige, die an einer ungewohnten Form lieber nichts zeigt, ist + // schlechter als eine, die das Rohe durchreicht. + $v = PveVersion::parse('irgendwas-anderes'); + + expect($v['version'])->toBe('irgendwas-anderes') + ->and($v['build'])->toBeNull(); +}); + +it('has nothing to say about a host that never reported a version', function () { + expect(PveVersion::parse(null)['version'])->toBeNull(); +}); + // Der Hetzner-DNS-Client steht in HetznerCloudDnsTest — seit dem Umzug auf die // Cloud-API (RRSets statt einzelner Records) gehört dazu mehr als zwei Fälle. // Die alte Seitenlauf-Falle, die hier stand, kann es nicht mehr geben: es gibt