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