Release v1.3.94 — die Kurven zeigen wieder, wie ausgelastet der Host ist
tests / pest (push) Failing after 9m19s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
main v1.3.94
nexxo 2026-08-01 11:56:10 +02:00
parent a7c7f81131
commit 55b749c129
9 changed files with 230 additions and 32 deletions

View File

@ -1 +1 @@
1.3.93
1.3.94

View File

@ -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). */

View File

@ -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');
}
}

View File

@ -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

View File

@ -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; }
}

View File

@ -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 !== [])
<svg class="spark {{ $tone === 'accent' ? '' : 'muted' }}" viewBox="0 0 96 34" preserveAspectRatio="none"
style="width:80px;height:32px" aria-hidden="true">
style="width:{{ (int) $width }}px;height:{{ (int) $height }}px" aria-hidden="true">
@if ($area)
{{-- A fade rather than a flat tint: the fill carries the eye down to
the baseline instead of ending in a hard edge halfway up. --}}
<defs>
<linearGradient id="{{ $fade }}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="currentColor" stop-opacity=".22" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
@endif
@foreach ($segments as $seg)
@if ($area)
{{-- Closed to the baseline under ITS OWN span, not under the whole
box: an area that ran from 0 to 96 would fill the gaps the
line above deliberately leaves open. --}}
<path class="area" d="M{{ $seg[0][0] }} 34 L{{ $coords($seg) }} L{{ end($seg)[0] }} 34 Z" />
<path class="area" fill="url(#{{ $fade }})" d="M{{ $seg[0][0] }} 34 L{{ $coords($seg) }} L{{ end($seg)[0] }} 34 Z" />
@endif
<path class="line" d="M{{ $coords($seg) }}" />
@endforeach

View File

@ -74,7 +74,7 @@
:unit="$now['cpu'] === null ? null : '%'"
:foot="$load['available'] ? __('hosts.detail.load_hint') : __('hosts.detail.no_load_short')">
@if ($load['available'])
<x-slot:visual><x-ui.spark :points="$load['cpu']" tone="muted" area /></x-slot:visual>
<x-slot:visual><x-ui.spark :points="$load['cpu']" :min="0" :max="100" tone="muted" area width="120" height="40" /></x-slot:visual>
@endif
</x-ui.metric>
@ -83,7 +83,7 @@
:unit="$now['ram'] === null ? null : '%'"
:foot="$load['available'] ? __('hosts.detail.load_hint') : __('hosts.detail.no_load_short')">
@if ($load['available'])
<x-slot:visual><x-ui.spark :points="$load['ram']" tone="muted" area /></x-slot:visual>
<x-slot:visual><x-ui.spark :points="$load['ram']" :min="0" :max="100" tone="muted" area width="120" height="40" /></x-slot:visual>
@endif
</x-ui.metric>
@ -104,7 +104,7 @@
:unit="$now['netin'] === null ? null : 'MiB/s'"
:foot="$load['available'] ? __('hosts.detail.load_hint') : __('hosts.detail.no_load_short')">
@if ($load['available'])
<x-slot:visual><x-ui.spark :points="$load['netin']" tone="muted" area /></x-slot:visual>
<x-slot:visual><x-ui.spark :points="$load['netin']" :min="0" tone="muted" area width="120" height="40" /></x-slot:visual>
@endif
</x-ui.metric>
@ -113,7 +113,7 @@
:unit="$now['netout'] === null ? null : 'MiB/s'"
:foot="$load['available'] ? __('hosts.detail.load_hint') : __('hosts.detail.no_load_short')">
@if ($load['available'])
<x-slot:visual><x-ui.spark :points="$load['netout']" tone="muted" area /></x-slot:visual>
<x-slot:visual><x-ui.spark :points="$load['netout']" :min="0" tone="muted" area width="120" height="40" /></x-slot:visual>
@endif
</x-ui.metric>
@ -136,7 +136,7 @@
Messung. --}}
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="mb-3 text-xs font-semibold text-muted">{{ __('hosts.detail.spec') }}</h2>
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-5">
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
@foreach ([
[__('hosts.detail.public_ip'), $host->public_ip ?? __('hosts.unknown')],
[__('hosts.meta.cores'), $host->cpu_cores ? (string) $host->cpu_cores : '—'],
@ -152,12 +152,11 @@
<div class="min-w-0">
<dt class="text-xs text-muted">{{ __('hosts.meta.version') }}</dt>
@if ($version['version'])
{{-- Die Zahl, die jemand sucht, statt der Bau-Kennung, in der
sie vorher abgeschnitten wurde. --}}
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink">Proxmox VE {{ $version['version'] }}</dd>
@if ($version['build'])
<dd class="truncate font-mono text-[11px] text-muted" title="{{ $host->pve_version }}">{{ __('hosts.detail.build') }} {{ $version['build'] }}</dd>
@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. --}}
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink" title="{{ $host->pve_version }}">Proxmox VE {{ $version['version'] }}</dd>
@else
<dd class="mt-0.5 truncate font-mono text-sm font-semibold text-ink">{{ __('hosts.unknown') }}</dd>
@endif

View File

@ -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('<details', false)
->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,10,3 %
'memused' => 2 + ($i % 3),
'memtotal' => 100, // 24 %
])->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);
});

View File

@ -42,7 +42,7 @@ it('closes the filled area with a path a renderer can actually follow', function
// Meldung.
$view = $this->blade('<x-ui.spark :points="$p" area />', ['p' => [10, 20, null, 40, 50]])->__toString();
preg_match_all('/class="area" d="([^"]+)"/', $view, $m);
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('<path');
});
// --- Maßstab -----------------------------------------------------------------
//
// Eine Verlaufslinie, die auf ihr EIGENES Minimum und Maximum skaliert, macht
// aus Rauschen ein Gebirge: ein Host mit 0,2 % CPU, der zwischen 0,1 und 0,3
// schwankt, zeichnete einen Seismographen — und widersprach damit der Zahl
// direkt daneben. Genau die Regel, die im Chart.js-Entwurf schon stand
// („eine automatisch skalierte Achse lässt 3 % wie eine Wand aussehen") und
// beim Umstieg auf die Kacheln verlorenging.
/** Alle y-Werte aus den gezeichneten Pfaden. */
function sparkY(string $html): array
{
preg_match_all('/class="line" d="M([^"]+)"/', $html, $m);
$ys = [];
foreach ($m[1] as $d) {
foreach (preg_split('/\s+/', trim($d)) as $i => $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('<x-ui.spark :points="$p" :min="0" :max="100" />', ['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('<x-ui.spark :points="$p" :min="0" :max="100" />', ['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('<x-ui.spark :points="$p" :min="0" />', ['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('<x-ui.spark :points="$p" />', ['p' => [10, 20]])->__toString();
expect(min(sparkY($html)))->toBe(4.0)
->and(max(sparkY($html)))->toBe(30.0);
});