diff --git a/VERSION b/VERSION index a24ba92..4b3a2de 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.92 +1.3.93 diff --git a/app/Provisioning/Jobs/CollectHostLoad.php b/app/Provisioning/Jobs/CollectHostLoad.php new file mode 100644 index 0000000..550b0ac --- /dev/null +++ b/app/Provisioning/Jobs/CollectHostLoad.php @@ -0,0 +1,73 @@ +onQueue('provisioning'); + } + + 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(); + + foreach ($hosts as $host) { + // collect() logs its own failures and never throws: one unreachable + // host must not stop the collection for the rest of the fleet. + $load->collect($host); + } + } +} diff --git a/app/Services/Proxmox/HostLoadSeries.php b/app/Services/Proxmox/HostLoadSeries.php index 3be9b91..cef7983 100644 --- a/app/Services/Proxmox/HostLoadSeries.php +++ b/app/Services/Proxmox/HostLoadSeries.php @@ -4,71 +4,124 @@ namespace App\Services\Proxmox; use App\Models\Host; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Throwable; /** - * The last hour of a host's CPU and memory load, as two percentage series. + * The last hour of a host's CPU, memory and network load, as percentage and + * MiB/s series. * - * Read from Proxmox's OWN recorded history (`/nodes/{node}/rrddata`) rather - * than sampled into a table of ours. PVE keeps hour/day/week/month/year for - * every node whether we ask or not, so a sampler would mean a new table, a - * minutely job, a pruning job and ~1440 rows per host per day — to reproduce, - * less accurately, something already on disk. It also means the curve is full - * from the first second, including the hour before anyone opened the page, and - * that it cannot disagree with what Proxmox's own interface shows. + * --------------------------------------------------------------------------- + * Fetching and reading are separate on purpose + * --------------------------------------------------------------------------- * - * The price is stated rather than hidden: a host that is not reachable has no - * curve. See `available` below. + * Only the `queue-provisioning` container is inside the WireGuard tunnel — it + * is the hub (NET_ADMIN, /dev/net/tun, the wireguard volume). The `app` + * container that renders the console has no route to 10.66.0.x at all, so an + * API call from a Livewire render could never be anything but a timeout. That + * is exactly what happened on the first real host: every tile read "no + * measurements" while the host was plainly online, and the swallowed exception + * meant the page could not say why. + * + * So collect() runs on the provisioning queue and writes the cache; forHost() + * is a pure read and never opens a socket. PingHosts states the same rule in + * its header — "runs on the provisioning queue, which is where the Proxmox + * credentials are usable" — and this is the second thing that had to learn it. + * + * --------------------------------------------------------------------------- + * Why Proxmox's own history and not a sampler of ours + * --------------------------------------------------------------------------- + * + * PVE keeps hour/day/week/month/year for every node whether we ask or not, so + * a sampler would mean a new table, a pruning job and ~1440 rows per host per + * day to reproduce, less accurately, something already on disk. The cache holds + * the hour we display; the history itself stays where it is written. */ final class HostLoadSeries { - /** Just under the minute at which PVE writes a fresh sample. */ - private const TTL_SECONDS = 55; - - public function __construct(private ProxmoxClient $pve) {} + /** + * Five minutes, while the collector runs every minute. + * + * Longer than the interval so one missed run does not blank a page that was + * fine a second ago — and short enough that a collector which has stopped + * takes the figures with it rather than leaving yesterday's load on screen + * looking current. + */ + private const TTL_SECONDS = 300; /** Bytes per second → MiB/s. The tiles carry the unit, so nobody has to guess. */ private const MIB = 1048576; + public function __construct(private ProxmoxClient $pve) {} + /** + * What the console shows. A pure cache read — never a request to the host. + * + * Nothing collected yet reads as "no measurements", which is the honest + * answer: it is the same thing an operator sees when the host is silent, + * and both are settled by the collector, not by the page. + * * @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:v2:'.$host->id, - self::TTL_SECONDS, - fn () => $this->read($host), - ); + return Cache::get($this->key($host), $this->empty()); } /** - * @return array{labels: array, cpu: array, ram: array, netin: array, netout: array, available: bool} + * Fetch the hour from Proxmox and put it where the console can read it. + * + * Runs ONLY where the tunnel is — see the class docblock. Failure is logged + * rather than swallowed: an empty tile that cannot say whether the host is + * silent or unreachable costs an afternoon to diagnose, and did. */ - private function read(Host $host): array + public function collect(Host $host): void { - $empty = ['labels' => [], 'cpu' => [], 'ram' => [], 'netin' => [], 'netout' => [], '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; + } catch (Throwable $e) { + Log::warning('host load: could not read the RRD history', [ + 'host' => $host->name, + 'node' => $host->node, + 'reason' => $e->getMessage(), + ]); + + return; } if ($rows === []) { - return $empty; + return; } + Cache::put($this->key($host), $this->series($rows), self::TTL_SECONDS); + } + + /** + * The cache key carries the shape's version. + * + * An entry outlives a deploy, and one written 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 never read again and expires on its own. + */ + private function key(Host $host): string + { + return 'host-load:v2:'.$host->id; + } + + /** @return array{labels: array, cpu: array, ram: array, netin: array, netout: array, available: bool} */ + private function empty(): array + { + return ['labels' => [], 'cpu' => [], 'ram' => [], 'netin' => [], 'netout' => [], 'available' => false]; + } + + /** + * @param array> $rows + * @return array{labels: array, cpu: array, ram: array, netin: array, netout: array, available: bool} + */ + private function series(array $rows): array + { $labels = []; $cpu = []; $ram = []; @@ -93,16 +146,6 @@ final class HostLoadSeries ]; } - /** 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); - } - /** * A share of a whole, in percent — or null when either half is missing. * @@ -120,4 +163,14 @@ final class HostLoadSeries return round((float) $part / (float) $whole * 100, 2); } + + /** 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/docs/superpowers/specs/2026-08-01-host-detailseite-last-design.md b/docs/superpowers/specs/2026-08-01-host-detailseite-last-design.md index 52b46c3..a928921 100644 --- a/docs/superpowers/specs/2026-08-01-host-detailseite-last-design.md +++ b/docs/superpowers/specs/2026-08-01-host-detailseite-last-design.md @@ -40,10 +40,37 @@ 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. +### Geholt wird, wo der Tunnel ist — gelesen, wo die Seite ist + +**Der Entwurf lag hier zuerst falsch, und echte Hardware hat es gezeigt.** Die +erste Fassung rief die Proxmox-API in `render()` auf. Nur der +`queue-provisioning`-Container hängt aber im WireGuard-Netz (`NET_ADMIN`, +`/dev/net/tun`, das `wireguard`-Volume); der `app`-Container, der die Konsole +rendert, erreicht `10.66.0.x` überhaupt nicht. Auf dem ersten echten Host blieb +deshalb **jede Kachel leer, während der Host sichtbar online war** — und weil +die Ausnahme verschluckt wurde, konnte die Seite den Grund nicht nennen. + +`PingHosts` schreibt dieselbe Regel seit Langem in seinen Kopf: *„runs on the +provisioning queue, which is where the Proxmox credentials are usable."* + +Also zwei Hälften: + +- **`HostLoadSeries::collect($host)`** holt und legt ab. Läuft nur im + `provisioning`-Container, über `Jobs\CollectHostLoad`, minütlich — der Takt, + in dem Proxmox einen frischen Messwert schreibt. Ein Fehlschlag wird + **protokolliert**, nicht verschluckt. +- **`HostLoadSeries::forHost($host)`** ist ein reines Lesen aus dem + Zwischenspeicher und öffnet nie eine Verbindung. Ein Test hält das fest + (`Http::assertNothingSent()`). + +Der Eintrag lebt **fünf Minuten** bei minütlichem Sammeln: länger als der Takt, +damit ein ausgefallener Lauf keine Seite leert, die eine Sekunde vorher in +Ordnung war — und kurz genug, dass ein stehengebliebener Sammler die Zahlen +mitnimmt, statt eine alte Stunde als aktuell stehenzulassen. + +**Derselbe Fehler steckt noch in `VmTemplateCheck`**, das aus der +Bereitschaftsseite heraus im `app`-Container die Proxmox-API fragt. Bestand, +nicht hier entstanden — eigener Punkt. ## 2. Die Kurve wirklich lebendig diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index caf05a2..11fb37f 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -121,8 +121,11 @@ :value="__('hosts.health.'.$health)" :foot="$host->last_seen_at ? __('hosts.detail.last_seen', ['time' => $host->last_seen_at->diffForHumans()]) : __('hosts.detail.never_seen')"> - - + {{-- Halb so groß wie der Ring nebenan, nicht gleich groß: eine + gefüllte Scheibe wiegt optisch weit mehr als ein dünner + Ring, und bei 62 px erschlug sie die Kachel. --}} + + diff --git a/routes/console.php b/routes/console.php index 8664dd4..dd72eba 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,7 @@ use App\Console\TickProvisioning; use App\Models\StripePendingEvent; +use App\Provisioning\Jobs\CollectHostLoad; use App\Provisioning\Jobs\CollectInstanceTraffic; use App\Provisioning\Jobs\PingHosts; use App\Provisioning\Jobs\RecordProvisioningHeartbeat; @@ -177,6 +178,17 @@ Schedule::job(new PingHosts) ->everyMinute() ->name('host-ping'); +// Die Stundenkurve auf der Host-Seite holen. +// +// Hier und nicht in der Seite, weil nur dieser Container im WireGuard-Tunnel +// hängt: der `app`-Container erreicht die Management-Adresse eines Hosts gar +// nicht, und ein API-Aufruf aus render() konnte deshalb nie etwas anderes sein +// als eine Zeitüberschreitung. Minütlich, weil Proxmox in diesem Takt einen +// frischen Messwert schreibt. +Schedule::job(new CollectHostLoad) + ->everyMinute() + ->name('host-load'); + // Carry out the downgrades whose term has run out. // // An upgrade lands the moment it is paid for; a downgrade waits, because diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index 43472c1..8ca59eb 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -12,6 +12,7 @@ use App\Models\User; use App\Provisioning\Jobs\AdvanceRunJob; use App\Provisioning\Jobs\PurgeHost; use App\Provisioning\Jobs\RemoveWireguardPeer; +use App\Services\Proxmox\HostLoadSeries; use App\Support\HostEnrolment; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Queue; @@ -327,20 +328,44 @@ it('gives every measurement its own tile, so none of them needs a legend', funct // Eine Reihe je Kachel: die Beschriftung benennt sie, und damit entfällt // die Legende, die vorher unter der gemeinsamen Kurve stand. $s = fakeServices(); + // Bewusst unverwechselbare Zahlen: „50" träfe auch die 500 GB in der + // Instanzenliste darunter, und ein Test, der aus Versehen recht behält, + // prüft nichts. Siehe die lose Zahlen-Falle, die das Repo schon kennt. $s['pve']->rrd = [ - ['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], + ['time' => 1754035200, 'cpu' => 0.11, 'memused' => 11, 'memtotal' => 100, 'netin' => 1_048_576, 'netout' => 1_048_576], + ['time' => 1754035260, 'cpu' => 0.37, 'memused' => 63, 'memtotal' => 100, 'netin' => 3_145_728, 'netout' => 5_242_880], ]; - $host = Host::factory()->active()->create(['node' => 'pve-fns-1']); + $host = Host::factory()->active()->create(['node' => 'pve-fns-1', 'api_token_ref' => 'ref']); + + // Der Sammler läuft im provisioning-Container, die Seite liest nur. Genau + // diese Reihenfolge, sonst prüft der Test einen Weg, den es nicht gibt. + app(HostLoadSeries::class)->collect($host); Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) ->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'); + // Der jüngste Messwert je Kachel, jeder eine Zahl, die sonst nirgends + // auf dieser Seite vorkommt. + ->assertSee('37') // CPU + ->assertSee('63') // RAM + ->assertSee('3,00') // eingehend, MiB/s + ->assertSee('5,00') // ausgehend + ->assertDontSee(__('hosts.detail.no_load_short')); +}); + +it('shows nothing until the collector has run, because the page cannot fetch', function () { + // 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. + $s = fakeServices(); + $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.25, 'memused' => 1, 'memtotal' => 2]]; + $host = Host::factory()->active()->create(['api_token_ref' => 'ref']); + + Livewire::actingAs(admin(), 'operator')->test(HostDetail::class, ['host' => $host]) + ->assertSee(__('hosts.detail.no_load_short')); }); it('shows the public ip on the page, not only in the small print', function () { diff --git a/tests/Feature/Provisioning/CollectHostLoadTest.php b/tests/Feature/Provisioning/CollectHostLoadTest.php new file mode 100644 index 0000000..08f9ca9 --- /dev/null +++ b/tests/Feature/Provisioning/CollectHostLoadTest.php @@ -0,0 +1,86 @@ +queue)->toBe('provisioning'); +}); + +it('leaves a series behind for every host worth asking', function () { + $s = fakeServices(); + $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]]; + $host = Host::factory()->active()->create(['api_token_ref' => 'ref']); + + (new CollectHostLoad)->handle(app(HostLoadSeries::class)); + + expect(app(HostLoadSeries::class)->forHost($host)['available'])->toBeTrue(); +}); + +it('skips a host that has no token to ask with', function () { + // Ein Host mitten in der Übernahme hat noch keinen — ihn zu fragen wäre ein + // sicherer Fehlschlag je Minute, und eine Zeile im Protokoll dazu. + $s = fakeServices(); + $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]]; + $host = Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => null]); + + (new CollectHostLoad)->handle(app(HostLoadSeries::class)); + + expect(app(HostLoadSeries::class)->forHost($host)['available'])->toBeFalse(); +}); + +it('keeps collecting after one host refuses to answer', function () { + // Eine Flotte darf nicht daran hängen, dass der erste Host schweigt. + $s = fakeServices(); + $s['pve']->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]]; + $silent = Host::factory()->active()->create(['api_token_ref' => 'ref-1', 'name' => 'aaa-still']); + $healthy = Host::factory()->active()->create(['api_token_ref' => 'ref-2', 'name' => 'zzz-antwortet']); + + // Der Fake wirft für den ersten Host und antwortet für den zweiten. + app()->instance(ProxmoxClient::class, new class($silent->id) extends FakeProxmoxClient + { + public function __construct(private int $silentId) + { + $this->rrd = [['time' => 1754035200, 'cpu' => 0.5, 'memused' => 1, 'memtotal' => 2]]; + } + + public function nodeRrdData(string $node, string $timeframe = 'hour'): array + { + if ($this->host?->id === $this->silentId) { + throw new RuntimeException('proxmox unreachable'); + } + + return $this->rrd; + } + }); + + (new CollectHostLoad)->handle(app(HostLoadSeries::class)); + + expect(app(HostLoadSeries::class)->forHost($silent)['available'])->toBeFalse() + ->and(app(HostLoadSeries::class)->forHost($healthy)['available'])->toBeTrue(); +}); + +it('never lets a second collector queue up behind a slow one', function () { + // Die provisioning-Warteschlange ist DIESELBE, auf der Kunden-Bereitstellung + // läuft. Ein Host, der in die Zeitüberschreitung läuft, kostet 15 Sekunden; + // bei genug Hosts ist der minütliche Lauf noch nicht fertig, wenn der + // nächste kommt — und der Rückstau verzögert dann bezahlte Arbeit. + // Dasselbe Mittel wie bei CollectInstanceTraffic nebenan. + expect(new CollectHostLoad)->toBeInstanceOf(ShouldBeUnique::class) + // Und nur ein Versuch: eine verpasste Minute wird von der nächsten + // Minute geholt, nicht von einer Wiederholung derselben. + ->and((new CollectHostLoad)->tries)->toBe(1); +}); diff --git a/tests/Feature/Provisioning/ServicesTest.php b/tests/Feature/Provisioning/ServicesTest.php index def153c..316fe26 100644 --- a/tests/Feature/Provisioning/ServicesTest.php +++ b/tests/Feature/Provisioning/ServicesTest.php @@ -16,6 +16,7 @@ use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; it('scripts remote command output and records calls (FakeRemoteShell)', function () { $shell = new FakeRemoteShell; @@ -169,6 +170,14 @@ it('reports an ordinary virtual machine carrying the vmid as no template', funct // ohnehin auf; hier wird daraus nur gerechnet — und die eine Regel eingehalten, // die eine Messreihe ehrlich hält: eine Lücke ist keine Null. +/** Holen (dort, wo der Tunnel ist) und lesen (dort, wo die Seite ist). */ +function collectAndRead(Host $host): array +{ + app(HostLoadSeries::class)->collect($host); + + return app(HostLoadSeries::class)->forHost($host); +} + function rrdRow(int $time, ?float $cpu, ?int $memused, ?int $memtotal): array { return array_filter([ @@ -185,7 +194,7 @@ it('turns proxmox rrd rows into cpu and ram percentages', function () { rrdRow(1754035260, 0.5, 34_359_738_368, 68_719_476_736), // 50 % CPU, 50 % RAM ]])]); - $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + $series = collectAndRead(proxmoxHost()); expect($series['cpu'])->toBe([1.25, 50.0]) ->and($series['ram'])->toBe([12.5, 50.0]) @@ -199,7 +208,7 @@ it('turns the recorded network rates into mebibytes per second', function () { ['time' => 1754035200, 'netin' => 2_097_152, 'netout' => 524_288], ]])]); - $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + $series = collectAndRead(proxmoxHost()); expect($series['netin'])->toBe([2.0]) ->and($series['netout'])->toBe([0.5]); @@ -211,7 +220,7 @@ it('leaves a missing network sample as a gap too', function () { ['time' => 1754035260], // Proxmox lässt die Felder weg ]])]); - $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + $series = collectAndRead(proxmoxHost()); expect($series['netin'])->toBe([1.0, null]) ->and($series['netout'])->toBe([1.0, null]); @@ -226,7 +235,7 @@ it('leaves a missing sample as a gap, never as zero', function () { rrdRow(1754035260, null, null, null), // Proxmox lässt die Felder weg ]])]); - $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + $series = collectAndRead(proxmoxHost()); expect($series['cpu'])->toBe([25.0, null]) ->and($series['ram'])->toBe([50.0, null]); @@ -237,7 +246,7 @@ it('reports no series at all when the host does not answer', function () { // sagen dürfen statt eine ruhige Stunde zu zeichnen. Http::fake(['*' => Http::response('gateway timeout', 502)]); - $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + $series = collectAndRead(proxmoxHost()); expect($series['cpu'])->toBe([]) ->and($series['ram'])->toBe([]) @@ -254,22 +263,51 @@ it('ignores a cached series written by an older version of itself', function () 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); + $series = collectAndRead($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. - Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.1, 1, 2)]])]); +it('never reaches for the host when the page asks', function () { + // DER Fehler, der die Kacheln auf echter Hardware leer ließ: nur der + // provisioning-Container hängt im WireGuard-Tunnel (NET_ADMIN, /dev/net/tun). + // Der `app`-Container, der die Seite rendert, erreicht 10.66.0.x gar nicht — + // ein API-Aufruf aus render() konnte deshalb nie etwas anderes als eine + // Zeitüberschreitung sein. Gelesen wird ab jetzt ausschließlich, was der + // Sammler hinterlegt hat. + Http::fake(); + + $series = app(HostLoadSeries::class)->forHost(proxmoxHost()); + + Http::assertNothingSent(); + expect($series['available'])->toBeFalse(); +}); + +it('serves the page from what the collector left behind', function () { + Http::fake(['*/rrddata*' => Http::response(['data' => [rrdRow(1754035200, 0.4, 1_048_576, 2_097_152)]])]); $host = proxmoxHost(); - app(HostLoadSeries::class)->forHost($host); - app(HostLoadSeries::class)->forHost($host); + app(HostLoadSeries::class)->collect($host); // im Tunnel + Http::fake(); // ab hier darf nichts mehr raus + $series = app(HostLoadSeries::class)->forHost($host); - Http::assertSentCount(1); + Http::assertNothingSent(); + expect($series['cpu'])->toBe([40.0]) + ->and($series['available'])->toBeTrue(); +}); + +it('leaves no measurements behind when the host cannot be reached', function () { + // Und schreibt den Grund ins Protokoll, statt ihn zu verschlucken: eine + // leere Kachel sagt nicht, ob der Host schweigt oder der Weg fehlt. + Log::spy(); + Http::fake(['*' => Http::response('gateway timeout', 502)]); + $host = proxmoxHost(); + + app(HostLoadSeries::class)->collect($host); + + expect(app(HostLoadSeries::class)->forHost($host)['available'])->toBeFalse(); + Log::shouldHaveReceived('warning')->once(); }); // --- PveVersion -------------------------------------------------------------