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); +});