diff --git a/VERSION b/VERSION index aa6058d..a24ba92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.91 +1.3.92 diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 17baa86..49ae456 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -7,7 +7,6 @@ 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; @@ -30,26 +29,6 @@ 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 { @@ -121,82 +100,24 @@ class HostDetail extends Component } /** - * Unix seconds → the wall clock the operator is reading it on (R19). + * The most recent reading that is actually a reading. * - * @param array $timestamps - * @return array + * Walked from the back rather than taking the last element: Proxmox omits + * the fields for a sample it has no data for, so the newest entry is quite + * often a gap — and a tile that showed 0 % because of it would be stating + * something about the host that nobody measured. + * + * @param array $series */ - private function clockLabels(array $timestamps): array + private function latest(array $series): ?float { - return array_map( - fn (int $t) => Carbon::createFromTimestamp($t)->local()->format('H:i'), - $timestamps, - ); - } + for ($i = count($series) - 1; $i >= 0; $i--) { + if ($series[$i] !== null) { + return $series[$i]; + } + } - /** - * 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]], - ], - ], - ]; + return null; } public function render() @@ -217,7 +138,13 @@ class HostDetail extends Component 'instances' => $this->host->instances()->latest('id')->get(), 'health' => $this->host->healthState(), 'load' => $load, - 'loadChart' => $this->chartConfig($load), + // Eine Zahl je Kachel, aus derselben Reihe, die die Kachel zeichnet. + 'now' => [ + 'cpu' => $this->latest($load['cpu']), + 'ram' => $this->latest($load['ram']), + 'netin' => $this->latest($load['netin']), + 'netout' => $this->latest($load['netout']), + ], 'version' => PveVersion::parse($this->host->pve_version), ]); } diff --git a/app/Services/Proxmox/HostLoadSeries.php b/app/Services/Proxmox/HostLoadSeries.php index f151461..3be9b91 100644 --- a/app/Services/Proxmox/HostLoadSeries.php +++ b/app/Services/Proxmox/HostLoadSeries.php @@ -27,24 +27,34 @@ final class HostLoadSeries public function __construct(private ProxmoxClient $pve) {} + /** Bytes per second → MiB/s. The tiles carry the unit, so nobody has to guess. */ + private const MIB = 1048576; + /** - * @return array{labels: array, cpu: array, ram: array, available: bool} + * @return array{labels: array, cpu: array, ram: array, netin: array, netout: array, available: bool} */ public function forHost(Host $host): array { + // The `v2` is not decoration. A cache entry outlives a deploy, and an + // entry written by the version before the network series has no + // `netin`/`netout` — read as one of today's it kills the host page with + // an undefined key for the length of the TTL, which is exactly the + // minute somebody is looking to see whether the update went through. + // The key moves with the shape; the old entry is simply never read + // again and expires on its own. return Cache::remember( - 'host-load:'.$host->id, + 'host-load:v2:'.$host->id, self::TTL_SECONDS, fn () => $this->read($host), ); } /** - * @return array{labels: array, cpu: array, ram: array, available: bool} + * @return array{labels: array, cpu: array, ram: array, netin: array, netout: array, available: bool} */ private function read(Host $host): array { - $empty = ['labels' => [], 'cpu' => [], 'ram' => [], 'available' => false]; + $empty = ['labels' => [], 'cpu' => [], 'ram' => [], 'netin' => [], 'netout' => [], 'available' => false]; try { $rows = $this->pve->forHost($host)->nodeRrdData($host->node ?? 'pve', 'hour'); @@ -62,14 +72,35 @@ final class HostLoadSeries $labels = []; $cpu = []; $ram = []; + $netin = []; + $netout = []; 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); + $netin[] = $this->rate($row['netin'] ?? null); + $netout[] = $this->rate($row['netout'] ?? null); } - return ['labels' => $labels, 'cpu' => $cpu, 'ram' => $ram, 'available' => true]; + return [ + 'labels' => $labels, + 'cpu' => $cpu, + 'ram' => $ram, + 'netin' => $netin, + 'netout' => $netout, + 'available' => true, + ]; + } + + /** Bytes per second as MiB/s — or null, for the same reason percent() returns null. */ + private function rate(mixed $bytesPerSecond): ?float + { + if (! is_numeric($bytesPerSecond)) { + return null; + } + + return round((float) $bytesPerSecond / self::MIB, 2); } /** diff --git a/lang/de/hosts.php b/lang/de/hosts.php index e1029d0..3435c7c 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -79,8 +79,14 @@ return [ 'load' => 'Last', 'load_hint' => 'letzte Stunde', 'no_load' => 'Keine Messwerte — der Host antwortet gerade nicht.', + 'no_load_short' => 'keine Messwerte', 'cpu' => 'CPU', 'ram' => 'RAM', + 'cpu_load' => 'CPU-Auslastung', + 'ram_load' => 'RAM-Auslastung', + 'net_in' => 'Eingehend', + 'net_out' => 'Ausgehend', + 'public_ip' => 'Öffentliche IP', 'spec' => 'Ausstattung', 'build' => 'Bau', 'onboarding_done' => 'Übernahme abgeschlossen', @@ -93,6 +99,7 @@ return [ 'committed' => ':gb GB belegt', 'reserve' => ':pct % Reserve', 'reserve_label' => 'Reserve', + 'reserve_hint' => 'Anteil des Speichers, der frei bleibt.', 'reserve_save' => 'Speichern', 'reserve_saved' => 'Reserve aktualisiert.', 'drain' => 'In Wartung', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index b2e9c5d..b143500 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -77,8 +77,14 @@ return [ 'load' => 'Load', 'load_hint' => 'last hour', 'no_load' => 'No measurements — the host is not answering right now.', + 'no_load_short' => 'no measurements', 'cpu' => 'CPU', 'ram' => 'RAM', + 'cpu_load' => 'CPU usage', + 'ram_load' => 'RAM usage', + 'net_in' => 'Inbound', + 'net_out' => 'Outbound', + 'public_ip' => 'Public IP', 'spec' => 'Specification', 'build' => 'Build', 'onboarding_done' => 'Onboarding complete', @@ -93,6 +99,7 @@ return [ 'committed' => ':gb GB committed', 'reserve' => ':pct % reserve', 'reserve_label' => 'Reserve', + 'reserve_hint' => 'Share of storage kept free.', 'reserve_save' => 'Save', 'reserve_saved' => 'Reserve updated.', 'drain' => 'Maintenance', diff --git a/resources/views/components/ui/spark.blade.php b/resources/views/components/ui/spark.blade.php index 97246b3..1e5b6ba 100644 --- a/resources/views/components/ui/spark.blade.php +++ b/resources/views/components/ui/spark.blade.php @@ -1,5 +1,5 @@ @props([ - /** @var array the series, in the order it happened */ + /** @var array the series, in the order it happened */ 'points' => [], // muted where the figure is observed, accent where it can be acted on. // Four accent charts in a row is how an accent stops meaning anything. @@ -7,25 +7,68 @@ 'area' => false, ]) @php - $values = array_values(array_filter($points, 'is_numeric')); - $min = $values ? min($values) : 0; - $max = $values ? max($values) : 0; + /* + | A missing sample BREAKS the line; it is not filtered out. + | + | This used to start with array_filter(…, 'is_numeric'), which threw the + | gaps away and joined their neighbours. Two things went wrong at once: the + | line claimed a measurement that was never taken, and every point after + | the gap slid left — an hour of samples with two gaps drew itself as + | fifty-eight minutes and put the newest reading somewhere in the middle. + | + | The x position is therefore taken from the point's place in the ORIGINAL + | series, and consecutive readings are collected into segments that are + | drawn as separate paths. A series with nothing missing yields exactly one + | segment and the same picture as before — which is what every existing + | caller passes. + */ + $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); $step = count($values) > 1 ? 96 / (count($values) - 1) : 96; - $path = ''; + $segments = []; + $current = []; + foreach ($values as $i => $v) { - $x = round($i * $step, 1); - $y = round(30 - (($v - $min) / $span) * 26, 1); - $path .= ($i === 0 ? 'M' : ' ').$x.' '.$y; + if (! is_numeric($v)) { + // A single reading on its own cannot be a line; it ends the segment + // without drawing one, rather than emitting a path of one point. + if (count($current) > 1) { + $segments[] = $current; + } + $current = []; + + continue; + } + + $current[] = [round($i * $step, 1), round(30 - (($v - $min) / $span) * 26, 1)]; } + + if (count($current) > 1) { + $segments[] = $current; + } + + // Nur die Koordinaten, ohne Befehlsbuchstaben — der Aufrufer setzt das `M` + // oder das `L` davor. Vorher trug diese Funktion ihr `M` selbst, und im + // 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)); @endphp -@if ($path !== '') +@if ($segments !== []) @endif diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index e82dbd4..caf05a2 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -1,7 +1,7 @@ {{-- 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> +
status, ['pending', 'running', 'waiting'])) wire:poll.4s @else wire:poll.60s @endif> @php $badge = ['pending' => 'pending', 'onboarding' => 'provisioning', 'active' => 'active', 'error' => 'failed', 'disabled' => 'suspended'][$host->status] ?? 'info'; @endphp @@ -47,89 +47,85 @@ {{-- Health + resource overview --}} @php $hdot = ['online' => 'bg-success-bright', 'stale' => 'bg-warning', 'offline' => 'bg-danger'][$health]; - $htone = ['online' => 'text-success', 'stale' => 'text-warning', 'offline' => 'text-danger'][$health]; + // Die Scheibe hinter dem Punkt trägt dieselbe Aussage in Farbe. Vorher + // war sie bg-surface-2 auf weißem Grund — technisch da, sichtbar nicht, + // und damit eine Fläche, die nichts sagt. + $hdisc = ['online' => 'bg-success-bg', 'stale' => 'bg-warning-bg', 'offline' => 'bg-danger-bg'][$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') }}

-

- - {{ __('hosts.health.'.$health) }} -

-

- {{ $host->last_seen_at ? __('hosts.detail.last_seen', ['time' => $host->last_seen_at->diffForHumans()]) : __('hosts.detail.never_seen') }} -

-
+ {{-- Ein Kachelraster statt dreier ungleicher Kästen. - {{-- Storage capacity --}} -
-

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

- @if ($host->total_gb) -

{{ $host->availableGb() }} / {{ $host->freeGb() }} GB {{ __('hosts.free') }}

-
-
-
- {{-- 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 -
+ Vorher standen Zustand, Speicher und eine gemeinsame Kurve nebeneinander: + zwei davon halb leer, die dritte überfüllt, und die gemeinsame Kurve + brauchte eine Legende, um zu sagen, welche Linie welche ist. - {{-- 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') }}

-
+ Eine Reihe je Kachel löst alle drei Beschwerden auf einmal — die + Beschriftung der Kachel benennt die Reihe, also braucht es keine + Legende; die Höhen sind durch das Raster gleich statt zufällig; und der + leere Platz ist mit Messwerten gefüllt, die es ohnehin schon gab. --}} +
+ @php + $pct = fn (?float $v) => $v === null ? '—' : number_format($v, $v < 10 ? 1 : 0, ',', '.'); + $mib = fn (?float $v) => $v === null ? '—' : number_format($v, 2, ',', '.'); + @endphp + + @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 -
+ + + + @if ($load['available']) + + @endif + + + {{-- Die Ablage als Ring, wie die Vorlage die Festplatte zeichnet. Der + Ring zeigt BELEGT, die Zahl daneben FREI — beides steht dabei, weil + ein Ring ohne Beschriftung beides bedeuten kann. --}} + + @if ($host->total_gb) + + @endif + + + + @if ($load['available']) + + @endif + + + + @if ($load['available']) + + @endif + + + + + + + + +
{{-- Ausstattung: was feststeht. Vorher standen diese Zahlen in Karten, die @@ -139,6 +135,7 @@

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

@foreach ([ + [__('hosts.detail.public_ip'), $host->public_ip ?? __('hosts.unknown')], [__('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')], @@ -163,6 +160,21 @@ @endif
+ + {{-- Die Reserve gehört hierher und nicht auf eine Kachel: die Kacheln + zeigen, was gemessen wurde, diese Tafel, was eingestellt ist. Ein + Eingabefeld zwischen Messwerten sähe aus, als ließe sich eine + Messung ändern. --}} + @if ($host->total_gb) +
+ {{ __('hosts.detail.reserve_label') }} + + % + + {{ __('hosts.detail.reserve_hint') }} +
+ @endif
{{-- Hosted instances. Die Anzahl steht in der Überschrift, statt als eigene diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index 7cf6f17..43472c1 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -323,21 +323,46 @@ it('shows the version number rather than the build hash it is buried in', functi ->assertSee('Proxmox VE 9.2.6'); }); -it('draws the load curve from what proxmox recorded', function () { +it('gives every measurement its own tile, so none of them needs a legend', function () { + // Eine Reihe je Kachel: die Beschriftung benennt sie, und damit entfällt + // die Legende, die vorher unter der gemeinsamen Kurve stand. $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], + ['time' => 1754035200, 'cpu' => 0.25, 'memused' => 34_359_738_368, 'memtotal' => 68_719_476_736, 'netin' => 1_048_576, 'netout' => 2_097_152], + ['time' => 1754035260, 'cpu' => 0.5, 'memused' => 17_179_869_184, 'memtotal' => 68_719_476_736, 'netin' => 2_097_152, 'netout' => 1_048_576], ]; $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(__('hosts.detail.cpu_load')) + ->assertSee(__('hosts.detail.ram_load')) + ->assertSee(__('hosts.detail.net_in')) + ->assertSee(__('hosts.detail.net_out')) + // Der jüngste Messwert je Kachel, nicht die Ausstattung. ->assertSee('50') ->assertSee('25'); }); +it('shows the public ip on the page, not only in the small print', function () { + // Die Adresse, unter der der Host wirklich erreichbar ist, stand nirgends + // in der Seite — nur klein unter der Überschrift. + fakeServices(); + $host = Host::factory()->active()->create(['public_ip' => '49.12.121.79']); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) + ->assertSee(__('hosts.detail.public_ip')) + ->assertSee('49.12.121.79'); +}); + +it('keeps the reserve control with the settings, not on a reading tile', function () { + fakeServices(); + $host = Host::factory()->active()->create(['reserve_pct' => 15]); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) + ->assertSee(__('hosts.detail.spec')) + ->assertSee(__('hosts.detail.reserve_label')); +}); + 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. @@ -345,7 +370,9 @@ it('says a silent host has no measurements instead of drawing a quiet hour', fun $host = Host::factory()->active()->create(); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) - ->assertSee(__('hosts.detail.no_load')); + // Jede Messkachel sagt es für sich, und keine zeigt eine 0. + ->assertSee(__('hosts.detail.no_load_short')) + ->assertSee(__('hosts.detail.cpu_load')); }); it('folds the finished onboarding away but keeps it reachable', function () { diff --git a/tests/Feature/Provisioning/ServicesTest.php b/tests/Feature/Provisioning/ServicesTest.php index 1996845..def153c 100644 --- a/tests/Feature/Provisioning/ServicesTest.php +++ b/tests/Feature/Provisioning/ServicesTest.php @@ -13,6 +13,7 @@ use App\Services\Wireguard\FakeWireguardHub; use App\Services\Wireguard\LocalWireguardHub; use App\Support\PveVersion; use Illuminate\Http\Client\RequestException; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; @@ -191,6 +192,31 @@ it('turns proxmox rrd rows into cpu and ram percentages', function () { ->and($series['labels'])->toHaveCount(2); }); +it('turns the recorded network rates into mebibytes per second', function () { + // Proxmox zählt Bytes je Sekunde. 2 097 152 B/s sind 2 MiB/s — und die + // Einheit steht in der Kachel daneben, damit niemand MB und MiB rät. + Http::fake(['*/rrddata*' => Http::response(['data' => [ + ['time' => 1754035200, 'netin' => 2_097_152, 'netout' => 524_288], + ]])]); + + $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + + expect($series['netin'])->toBe([2.0]) + ->and($series['netout'])->toBe([0.5]); +}); + +it('leaves a missing network sample as a gap too', function () { + Http::fake(['*/rrddata*' => Http::response(['data' => [ + ['time' => 1754035200, 'netin' => 1_048_576, 'netout' => 1_048_576], + ['time' => 1754035260], // Proxmox lässt die Felder weg + ]])]); + + $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + + expect($series['netin'])->toBe([1.0, null]) + ->and($series['netout'])->toBe([1.0, null]); +}); + 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 @@ -218,6 +244,22 @@ it('reports no series at all when the host does not answer', function () { ->and($series['available'])->toBeFalse(); }); +it('ignores a cached series written by an older version of itself', function () { + // Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus der Fassung + // vor den Netz-Reihen kennt netin/netout nicht — gelesen wie einer von + // heute, stirbt die Host-Seite 55 Sekunden lang an einem fehlenden + // Schlüssel, und zwar genau in dem Moment, in dem jemand nachsieht, ob das + // Update durch ist. + $host = proxmoxHost(); + Cache::put('host-load:'.$host->id, ['labels' => [1], 'cpu' => [1.0], 'ram' => [2.0], 'available' => true], 600); + Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1_048_576, 2_097_152)]])]); + + $series = app(HostLoadSeries::class)->forHost($host); + + expect($series)->toHaveKeys(['netin', 'netout']) + ->and($series['cpu'])->toBe([10.0]); // frisch geholt, nicht der alte Eintrag +}); + 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. diff --git a/tests/Feature/SparkGapTest.php b/tests/Feature/SparkGapTest.php new file mode 100644 index 0000000..c813982 --- /dev/null +++ b/tests/Feature/SparkGapTest.php @@ -0,0 +1,59 @@ +blade('', ['p' => [10, 20, null, 40, 50]]); + + // Zwei Züge: einer vor der Lücke, einer danach. Ein einziges `M` hieße, + // die Linie läuft durch. + expect(substr_count($view->__toString(), 'toBe(2); +}); + +it('draws one unbroken line when nothing is missing', function () { + // Die Gegenprobe: an sauberen Reihen — und das sind alle bisherigen + // Aufrufer — darf sich nichts ändern. + $view = $this->blade('', ['p' => [10, 20, 30, 40, 50]]); + + expect(substr_count($view->__toString(), 'toBe(1); +}); + +it('keeps later points in place when an earlier one is missing', function () { + // Der eigentliche Schaden am Filtern: der letzte Punkt einer Stunde muss + // ganz rechts liegen, egal wie viele Messwerte dazwischen fehlen. + $withGap = $this->blade('', ['p' => [10, null, 30, 40, 50]])->__toString(); + $without = $this->blade('', ['p' => [10, 20, 30, 40, 50]])->__toString(); + + expect($withGap)->toContain('96 ') // rechter Rand erreicht + ->and($without)->toContain('96 '); +}); + +it('closes the filled area with a path a renderer can actually follow', function () { + // Beim Zerlegen in Züge ist der Flächenpfad kurz `… L` direkt vor einem `M` + // geworden — gültig sieht das aus, gezeichnet wird es nicht, und die + // Füllung verschwand still. Ein Blick auf die Seite hat es gezeigt, keine + // Meldung. + $view = $this->blade('', ['p' => [10, 20, null, 40, 50]])->__toString(); + + preg_match_all('/class="area" d="([^"]+)"/', $view, $m); + + expect($m[1])->toHaveCount(2); + foreach ($m[1] as $d) { + expect($d)->toStartWith('M') + ->and($d)->not->toContain('LM') // ein Befehl direkt hinter einem anderen + ->and($d)->toEndWith('Z'); + } +}); + +it('draws nothing at all when every sample is missing', function () { + $view = $this->blade('', ['p' => [null, null]]); + + expect($view->__toString())->not->toContain('