From 55b749c129df0dcf59b2cd10d434b7d49b3f45d5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 11:56:10 +0200 Subject: [PATCH] =?UTF-8?q?Release=20v1.3.94=20=E2=80=94=20die=20Kurven=20?= =?UTF-8?q?zeigen=20wieder,=20wie=20ausgelastet=20der=20Host=20ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Punkte des Besitzers, und der dritte hat den eigentlichen Fehler freigelegt. Die Verlaufslinien sahen komisch aus, weil sie auf ihr EIGENES Minimum und Maximum skalierten. Ein Host mit 0,2 % CPU, der zwischen 0,1 und 0,3 schwankt, zeichnete damit einen Seismographen über die volle Höhe — und widersprach flach der Zahl direkt daneben. Dieselbe Regel stand im Chart.js-Entwurf schon ausformuliert ("eine automatisch skalierte Achse lässt 3 % wie eine Wand aussehen") und ging beim Umstieg auf die Kacheln verloren. x-ui.spark bekommt deshalb `min`/`max`. Ohne Angabe bleibt alles wie bisher — das ist, was jeder bestehende Aufrufer übergibt. Die Prozent-Kacheln geben 0 und 100 mit, die Netz-Kacheln nur die 0, weil MiB/s keine natürliche Obergrenze haben. Ein untätiger Host zeichnet jetzt vier ruhige Linien statt zweier wogender und zweier flacher — vorher wirkte CPU (0,2 %) belebter als RAM (3,6 %), also genau verkehrt herum. Dazu: Werte außerhalb der Grenzen werden geklemmt statt aus dem Kasten gezeichnet, die Fläche ist ein Verlauf statt einer harten Kante, und die Linien sind mit 120×40 statt 80×32 lesbar. Ich hatte max=100 zwischendurch selbst verworfen, weil es "zu tot" aussah — auf einem Bild, auf dem die Füllung wegen eines CSS-Fehlers gar nicht gezeichnet wurde. Ein Vergleich mit einem kaputten Bild. Der Fehler: `.spark path { fill: none }` schlägt als CSS-Regel das Präsentationsattribut fill="url(#…)". `fill: none` gehört an die Linie, nicht an jeden Pfad. Die Ausstattung ist wieder einzeilig. Sechs Felder, sechs Spalten — und die Bau-Kennung steht nur noch im Titel: als eigene Zeile zwang sie die ganze Tafel in eine zweite Reihe, für eine Zeichenkette, die fast niemand liest. Und die Wartezeit: die Seite stößt beim Öffnen eine Sammlung an, wenn noch keine Messwerte da sind, statt bis zum nächsten minütlichen Lauf leer zu bleiben. Ein leerer Kasten liest sich als "kaputt", nicht als "gleich". Der Job ist ShouldBeUnique, zwei geöffnete Seiten reihen also keine zwei ein. Codex-Befund dazu (P2): das galt auch für Hosts, die der Sammler ohnehin überspringt — ein Host mitten in der Übernahme hat keinen Token, und seine Seite hätte die ganze Flotte abgeklappert und die eigenen Kacheln trotzdem leer gelassen. Wer gesammelt wird, steht jetzt einmal am Modell (Host::collectable), gelesen von Job und Seite. Zwei Fassungen dieser Frage waren genau der Grund. Geprüft: 2307 Tests grün, Pint sauber, Codex ohne Befund. Der Test für den Maßstab prüft jetzt auch die echten Aufrufstellen und nicht nur das Bauteil — gegengeprobt durch Entfernen von max=100, dann fällt er. Im Browser beide Fälle angesehen: ruhiger Host vier flache Linien, ausgelasteter Host lesbare Form, null Konsolenfehler. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Livewire/Admin/HostDetail.php | 13 ++++ app/Models/Host.php | 20 ++++++ app/Provisioning/Jobs/CollectHostLoad.php | 13 ++-- resources/css/app.css | 16 +++-- resources/views/components/ui/spark.blade.php | 46 ++++++++++-- .../livewire/admin/host-detail.blade.php | 21 +++--- tests/Feature/Admin/HostManagementTest.php | 72 ++++++++++++++++++- tests/Feature/SparkGapTest.php | 59 ++++++++++++++- 9 files changed, 230 insertions(+), 32 deletions(-) diff --git a/VERSION b/VERSION index 4b3a2de..00cf78c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.93 +1.3.94 diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 49ae456..c642418 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -5,6 +5,7 @@ namespace App\Livewire\Admin; use App\Models\Host; use App\Models\ProvisioningRun; use App\Provisioning\Jobs\AdvanceRunJob; +use App\Provisioning\Jobs\CollectHostLoad; use App\Services\Proxmox\HostLoadSeries; use App\Support\PveVersion; use Illuminate\Support\Collection; @@ -20,6 +21,18 @@ class HostDetail extends Component public function mount(Host $host): void { $this->host = $host; + + // Nothing collected yet? Ask for it now rather than leaving the tiles + // empty until the minutely collector next comes round. Opening the page + // is exactly the moment somebody wants these figures, and waiting up to + // a minute for the first ones reads as "broken", not as "not yet". + // + // The job is ShouldBeUnique, so a page opened twice does not queue two. + $worthAsking = Host::query()->collectable()->whereKey($host->id)->exists(); + + if ($worthAsking && ! app(HostLoadSeries::class)->forHost($host)['available']) { + CollectHostLoad::dispatch(); + } } /** Live refresh whenever any run advances (admins-only channel). */ diff --git a/app/Models/Host.php b/app/Models/Host.php index 15e02a7..7b2cb83 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -6,6 +6,7 @@ use App\Models\Concerns\HasUuid; use App\Provisioning\Contracts\ProvisioningSubject; use App\Services\Secrets\SecretCipher; use Database\Factories\HostFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -240,4 +241,23 @@ class Host extends Model implements ProvisioningSubject { return $this->status === 'active' && $this->availableGb() >= $quotaGb; } + + /** + * Hosts whose load is worth collecting: reachable in principle, and with a + * token to ask with. + * + * `error` is in deliberately — a host in trouble is exactly the one an + * operator opens the page for. One still being onboarded has no token yet, + * and a query per minute against it would only fill the log. + * + * A scope rather than two copies of the same `whereIn`, because the two + * readers must not drift: Jobs\CollectHostLoad decides who gets collected, + * and Admin\HostDetail decides whether opening a page is worth asking for a + * collection. When those disagreed, opening a host mid-onboarding scanned + * the whole fleet and still left its tiles empty. + */ + public function scopeCollectable(Builder $query): Builder + { + return $query->whereIn('status', ['active', 'error'])->whereNotNull('api_token_ref'); + } } diff --git a/app/Provisioning/Jobs/CollectHostLoad.php b/app/Provisioning/Jobs/CollectHostLoad.php index 550b0ac..a16dbbf 100644 --- a/app/Provisioning/Jobs/CollectHostLoad.php +++ b/app/Provisioning/Jobs/CollectHostLoad.php @@ -55,14 +55,11 @@ class CollectHostLoad implements ShouldBeUnique, ShouldQueue public function handle(HostLoadSeries $load): void { - // The same two states PingHosts asks about: a host still being onboarded - // has no token yet, and a retired one is nobody's dashboard. `error` is - // included deliberately — a host in trouble is exactly the one an - // operator opens the page for. - $hosts = Host::query() - ->whereIn('status', ['active', 'error']) - ->whereNotNull('api_token_ref') - ->get(); + // Who counts as collectable lives on the model, in one place: this job + // and the host page both read it, and when they disagreed, opening a + // host mid-onboarding scanned the whole fleet and still left its tiles + // empty. + $hosts = Host::query()->collectable()->get(); foreach ($hosts as $host) { // collect() logs its own failures and never throws: one unreachable diff --git a/resources/css/app.css b/resources/css/app.css index 520f218..ead523f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -134,20 +134,28 @@ } @keyframes ring-sweep { to { stroke-dashoffset: var(--o, 0); } } -.spark path { fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; } +/* `fill: none` gehört an die LINIE, nicht an jeden Pfad: eine CSS-Regel schlägt + ein Präsentationsattribut, und `.spark path { fill: none }` machte damit das + fill="url(#…)" der Fläche wirkungslos — die Füllung verschwand still. */ +.spark path { stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; } .spark .line { + fill: none; stroke: var(--accent); stroke-dasharray: 400; stroke-dashoffset: 400; animation: spark-draw 1.3s cubic-bezier(.4, .1, .2, 1) .2s forwards; } .spark.muted .line { stroke: var(--text-muted); } -.spark .area { fill: var(--accent); stroke: none; opacity: 0; animation: spark-fade .8s ease .9s forwards; } +/* Die Farbe kommt jetzt über currentColor in den Verlauf; `fill` setzt das + Bauteil selbst auf url(#…). Bleibt hier nur das Einblenden. */ +.spark { color: var(--accent); } +.spark.muted { color: var(--text-muted); } +.spark .area { stroke: none; opacity: 0; animation: spark-fade .8s ease .9s forwards; } @keyframes spark-draw { to { stroke-dashoffset: 0; } } -@keyframes spark-fade { to { opacity: .09; } } +@keyframes spark-fade { to { opacity: 1; } } @media (prefers-reduced-motion: reduce) { .metric-ring .value { stroke-dashoffset: var(--o, 0) !important; } .spark .line { stroke-dashoffset: 0 !important; } - .spark .area { opacity: .09 !important; } + .spark .area { opacity: 1 !important; } } diff --git a/resources/views/components/ui/spark.blade.php b/resources/views/components/ui/spark.blade.php index 1e5b6ba..138a0cc 100644 --- a/resources/views/components/ui/spark.blade.php +++ b/resources/views/components/ui/spark.blade.php @@ -5,6 +5,23 @@ // Four accent charts in a row is how an accent stops meaning anything. 'tone' => 'accent', 'area' => false, + /* + | The scale. Left null, the line stretches over its own smallest and largest + | value — the classic sparkline, and the right choice when the question is + | "what shape did this take". + | + | It is the WRONG choice when the question is "how busy is this", and the + | host page proved it: a machine sitting at 0.2 % CPU, wobbling between 0.1 + | and 0.3, drew a seismograph and flatly contradicted the figure printed + | beside it. Passing min=0 (and max=100 for a percentage) anchors the box so + | a calm host looks calm — the same rule the Chart.js version already + | carried as "an auto-scaled axis makes 3 % look like a wall of load". + */ + 'min' => null, + 'max' => null, + // Rendered size in px. The viewBox stays 96×34, so this only stretches. + 'width' => 80, + 'height' => 32, ]) @php /* @@ -25,9 +42,9 @@ $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); + $low = $min ?? ($numeric ? min($numeric) : 0); + $high = $max ?? ($numeric ? max($numeric) : 0); + $span = max(0.0001, $high - $low); $step = count($values) > 1 ? 96 / (count($values) - 1) : 96; $segments = []; @@ -45,7 +62,10 @@ continue; } - $current[] = [round($i * $step, 1), round(30 - (($v - $min) / $span) * 26, 1)]; + // Clamped: a value outside the given bounds belongs at the edge, not + // outside the box where it would be clipped into a straight line. + $ratio = min(1, max(0, ((float) $v - $low) / $span)); + $current[] = [round($i * $step, 1), round(30 - $ratio * 26, 1)]; } if (count($current) > 1) { @@ -57,16 +77,30 @@ // 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)); + + // Own id per render: two gradients sharing one would be harmless, but a + // fill referring to a def that a later render replaced would not. + $fade = 'spark-fade-'.substr(md5($tone.serialize($values).$width), 0, 10); @endphp @if ($segments !== [])

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

-
+
@foreach ([ [__('hosts.detail.public_ip'), $host->public_ip ?? __('hosts.unknown')], [__('hosts.meta.cores'), $host->cpu_cores ? (string) $host->cpu_cores : '—'], @@ -152,12 +152,11 @@
{{ __('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 + {{-- Die Zahl, die jemand sucht. Die Bau-Kennung steht nur noch + im Titel: als eigene Zeile zwang sie die ganze Tafel in + eine zweite Reihe, für eine Zeichenkette, die fast + niemand liest und niemand abtippt. --}} +
Proxmox VE {{ $version['version'] }}
@else
{{ __('hosts.unknown') }}
@endif diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index 8ca59eb..65bb7d2 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -10,6 +10,7 @@ use App\Models\Host; use App\Models\ProvisioningRun; use App\Models\User; use App\Provisioning\Jobs\AdvanceRunJob; +use App\Provisioning\Jobs\CollectHostLoad; use App\Provisioning\Jobs\PurgeHost; use App\Provisioning\Jobs\RemoveWireguardPeer; use App\Services\Proxmox\HostLoadSeries; @@ -359,7 +360,9 @@ it('shows nothing until the collector has run, because the page cannot fetch', f // Der Fehler auf echter Hardware: der `app`-Container hängt nicht im // WireGuard-Tunnel. Eine Seite, die selbst holt, zeigt dort für immer // Leerstellen — und behauptet damit etwas über den Host, das nur über den - // Weg dorthin stimmt. + // Weg dorthin stimmt. Queue::fake() hält den Sammler an, damit dieser Test + // den Zustand VOR seinem ersten Lauf zeigt. + Queue::fake(); $s = fakeServices(); $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.25, 'memused' => 1, 'memtotal' => 2]]; $host = Host::factory()->active()->create(['api_token_ref' => 'ref']); @@ -421,3 +424,70 @@ it('keeps the stepper open while the onboarding is still running', function () { ->assertDontSee('assertSee(__('hosts.progress')); }); + +it('asks for a collection at once when the page finds no measurements', function () { + // Sonst bleiben die Kacheln bis zum nächsten minütlichen Lauf leer, und ein + // leerer Kasten liest sich als "kaputt", nicht als "gleich". + Queue::fake(); + fakeServices(); + $host = Host::factory()->active()->create(['api_token_ref' => 'ref']); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]); + + Queue::assertPushed(CollectHostLoad::class); +}); + +it('does not ask again when the measurements are already there', function () { + Queue::fake(); + $s = fakeServices(); + $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]]; + $host = Host::factory()->active()->create(['api_token_ref' => 'ref']); + app(HostLoadSeries::class)->collect($host); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]); + + Queue::assertNotPushed(CollectHostLoad::class); +}); + +it('draws an idle host as idle, on the page itself', function () { + // Der Test für den Maßstab prüfte bisher nur das Bauteil. Die Kacheln + // müssen ihm die Grenzen aber auch WIRKLICH mitgeben — ohne max=100 zeichnet + // ein Host mit 0,2 % CPU wieder eine Welle über die volle Höhe und + // widerspricht der Zahl daneben. + $s = fakeServices(); + $s['pve']->rrd = collect(range(0, 20))->map(fn ($i) => [ + 'time' => 1754035200 + $i * 60, + 'cpu' => 0.001 + ($i % 3) * 0.001, // 0,1–0,3 % + 'memused' => 2 + ($i % 3), + 'memtotal' => 100, // 2–4 % + ])->all(); + $host = Host::factory()->active()->create(['api_token_ref' => 'ref']); + app(HostLoadSeries::class)->collect($host); + + $html = Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host])->html(); + + preg_match_all('/class="line" d="M([^"]+)"/', $html, $m); + expect($m[1])->not->toBeEmpty(); + + foreach ($m[1] as $d) { + foreach (preg_split('/\s+/', trim($d)) as $i => $n) { + // 30 ist die Grundlinie, 4 die Oberkante. + if ($i % 2 === 1) { + expect((float) $n)->toBeGreaterThan(28.0); + } + } + } +}); + +it('does not start a fleet-wide collection for a host the collector skips', function () { + // Ein Host mitten in der Übernahme hat noch keinen Token. Der Sammler + // überspringt ihn — ein Aufruf seiner Seite würde also die ganze Flotte + // abklappern und diese Kacheln trotzdem leer lassen. + Queue::fake(); + fakeServices(); + $host = Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => null]); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]); + + Queue::assertNotPushed(CollectHostLoad::class); +}); diff --git a/tests/Feature/SparkGapTest.php b/tests/Feature/SparkGapTest.php index c813982..3360834 100644 --- a/tests/Feature/SparkGapTest.php +++ b/tests/Feature/SparkGapTest.php @@ -42,7 +42,7 @@ it('closes the filled area with a path a renderer can actually follow', function // Meldung. $view = $this->blade('', ['p' => [10, 20, null, 40, 50]])->__toString(); - preg_match_all('/class="area" d="([^"]+)"/', $view, $m); + preg_match_all('/class="area"[^>]*\sd="([^"]+)"/', $view, $m); expect($m[1])->toHaveCount(2); foreach ($m[1] as $d) { @@ -57,3 +57,60 @@ it('draws nothing at all when every sample is missing', function () { expect($view->__toString())->not->toContain(' $n) { + if ($i % 2 === 1) { + $ys[] = (float) $n; + } + } + } + + return $ys; +} + +it('keeps a nearly idle host near the baseline instead of filling the box', function () { + $html = $this->blade('', ['p' => [0.1, 0.3, 0.2, 0.25]])->__toString(); + + // 30 ist die Grundlinie, 4 die Oberkante. Bei 0,2 % darf nichts hochspringen. + expect(max(sparkY($html)))->toBeGreaterThan(29.0) + ->and(min(sparkY($html)))->toBeGreaterThan(29.0); +}); + +it('still uses the whole box when the load really does span it', function () { + $html = $this->blade('', ['p' => [0, 100]])->__toString(); + + expect(min(sparkY($html)))->toBe(4.0) // 100 % ganz oben + ->and(max(sparkY($html)))->toBe(30.0); // 0 % auf der Grundlinie +}); + +it('draws a flat line at the bottom when nothing moved at all', function () { + // Die Netz-Kacheln eines ruhigen Hosts: lauter Nullen. Ohne verankerte + // Untergrenze wurde daraus eine Linie irgendwo in der Mitte. + $html = $this->blade('', ['p' => [0, 0, 0, 0]])->__toString(); + + expect(array_unique(sparkY($html)))->toBe([30.0]); +}); + +it('scales to the series itself when no bounds are given', function () { + // Die Gegenprobe: alle bisherigen Aufrufer geben keine Grenzen mit und + // dürfen sich nicht verändern. + $html = $this->blade('', ['p' => [10, 20]])->__toString(); + + expect(min(sparkY($html)))->toBe(4.0) + ->and(max(sparkY($html)))->toBe(30.0); +});