Release v1.3.93 — die Seite konnte den Host gar nicht fragen
Auf echter Hardware blieb jede Messkachel leer, während der Host sichtbar online war und vor zwei Minuten geantwortet hatte. Kein Zufall und kein Aussetzer: ein Entwurfsfehler. Nur der queue-provisioning-Container hängt im WireGuard-Netz — NET_ADMIN, /dev/net/tun, das wireguard-Volume. Der app-Container, der die Konsole rendert, hat überhaupt keine Route zu 10.66.0.x. Ich hatte den Proxmox-Aufruf in render() gelegt, also in den einen Container, der die Management-Adresse nicht erreichen kann. Ein Aufruf von dort konnte nie etwas anderes sein als eine Zeitüberschreitung. PingHosts schreibt dieselbe Regel seit Langem in seinen Kopf: "runs on the provisioning queue, which is where the Proxmox credentials are usable." Ich habe sie gelesen und nicht angewendet. Verschlimmert hat es mein eigenes catch (Throwable): der Grund wurde verschluckt, und die Kachel sagte "keine Messwerte" — ununterscheidbar davon, dass der Host schweigt. Auf dem Testhost fiel nichts auf, weil TEST-NET ohnehin nie antwortet. Jetzt zwei Hälften: - HostLoadSeries::collect() holt und legt ab, aus Jobs\CollectHostLoad auf der provisioning-Warteschlange, minütlich — der Takt, in dem Proxmox einen frischen Messwert schreibt. Ein Fehlschlag wird protokolliert, mit Host, Node und Grund. - HostLoadSeries::forHost() liest nur aus dem Zwischenspeicher und öffnet nie eine Verbindung. Ein Test hält das mit Http::assertNothingSent() fest. 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. Der Sammler ist ShouldBeUnique (Codex-Befund, P1). Die provisioning-Warteschlange ist DIESELBE, auf der Kunden-Bereitstellung läuft; ein stiller Host kostet den vollen HTTP-Zeitablauf, und ohne diese Sperre stauten sich minütlich neue Läufe hinter dem alten und verzögerten bezahlte Arbeit. Dasselbe Mittel, das CollectInstanceTraffic nebenan schon benutzt. Dazu: der Zustands-Punkt war mit 62 px so groß wie der Speicher-Ring nebenan. Eine gefüllte Scheibe wiegt optisch weit mehr als ein dünner Ring und erschlug die Kachel — jetzt 32 px. Und ein Test, der aus Versehen recht behielt: die Kachel-Prüfung verglich mit "50", was auch die 500 GB in der Instanzenliste darunter trifft. Sie prüft jetzt Zahlen, die sonst nirgends auf der Seite vorkommen. Noch offen, nicht hier angefasst: VmTemplateCheck fragt die Proxmox-API ebenfalls aus dem app-Container heraus, von der Bereitschaftsseite aus. Selber Fehler, Bestand, eigener Punkt. Geprüft: 2299 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit eingespielten Messwerten: sechs gefüllte Kacheln, Zustands-Scheibe in Proportion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main v1.3.93
parent
589489a9fc
commit
dc35e5310f
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Provisioning\Jobs;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Services\Proxmox\HostLoadSeries;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Fetches the last hour of load from every host that is worth asking, and puts
|
||||
* it where the console can read it.
|
||||
*
|
||||
* On the `provisioning` queue, and that is the whole reason this job exists:
|
||||
* only that container is inside the WireGuard tunnel. The `app` container has
|
||||
* no route to a host's management address, so the console cannot fetch this
|
||||
* itself — it reads what this job left behind. Same rule, same queue and the
|
||||
* same reason as PingHosts next door.
|
||||
*
|
||||
* Every minute, because that is how often Proxmox writes a fresh sample. The
|
||||
* cache entry lives five minutes, so one missed run does not blank a page,
|
||||
* and a collector that has stopped takes the figures with it instead of
|
||||
* leaving an old hour on screen looking current.
|
||||
*/
|
||||
class CollectHostLoad implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* One collector at a time, fleet-wide.
|
||||
*
|
||||
* The `provisioning` queue is the SAME queue that carries out customer
|
||||
* provisioning. A host that has gone quiet costs this job the full HTTP
|
||||
* timeout, so with enough of them a minutely run is not finished when the
|
||||
* next one is dispatched — and the backlog then delays work somebody has
|
||||
* paid for. Same guard, same reason as CollectInstanceTraffic next door.
|
||||
*/
|
||||
public int $uniqueFor = 600;
|
||||
|
||||
/**
|
||||
* A missed minute is fetched by the next minute, not by a retry of this
|
||||
* one: the RRD is a rolling hour, so a repeat would ask for almost exactly
|
||||
* what the following run asks for anyway.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, 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<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, 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<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, available: bool} */
|
||||
private function empty(): array
|
||||
{
|
||||
return ['labels' => [], 'cpu' => [], 'ram' => [], 'netin' => [], 'netout' => [], 'available' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array{labels: array<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, netin: array<int, ?float>, netout: array<int, ?float>, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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')">
|
||||
<x-slot:visual>
|
||||
<span class="grid size-[62px] place-items-center rounded-pill {{ $hdisc }}">
|
||||
<span class="size-3.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
|
||||
{{-- 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. --}}
|
||||
<span class="grid size-8 place-items-center rounded-pill {{ $hdisc }}">
|
||||
<span class="size-2.5 rounded-pill {{ $hdot }} {{ $health === 'online' ? 'animate-pulse' : '' }}" aria-hidden="true"></span>
|
||||
</span>
|
||||
</x-slot:visual>
|
||||
</x-ui.metric>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Provisioning\Jobs\CollectHostLoad;
|
||||
use App\Services\Proxmox\FakeProxmoxClient;
|
||||
use App\Services\Proxmox\HostLoadSeries;
|
||||
use App\Services\Proxmox\ProxmoxClient;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
|
||||
/**
|
||||
* Der Sammler holt die Stundenkurve dort, wo der Tunnel ist.
|
||||
*
|
||||
* Er existiert genau deshalb: nur der `provisioning`-Container hängt im
|
||||
* WireGuard-Netz. Der `app`-Container, der die Konsole rendert, erreicht die
|
||||
* Management-Adresse eines Hosts gar nicht — auf echter Hardware blieben
|
||||
* deshalb alle Kacheln leer, während der Host sichtbar online war.
|
||||
*/
|
||||
it('runs on the queue that can actually reach a host', function () {
|
||||
// Kein Detail: dieselbe Warteschlange wie PingHosts, aus demselben Grund.
|
||||
expect((new CollectHostLoad)->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);
|
||||
});
|
||||
|
|
@ -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 -------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue