177 lines
6.7 KiB
PHP
177 lines
6.7 KiB
PHP
<?php
|
|
|
|
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, memory and network load, as percentage and
|
|
* MiB/s series.
|
|
*
|
|
* ---------------------------------------------------------------------------
|
|
* Fetching and reading are separate on purpose
|
|
* ---------------------------------------------------------------------------
|
|
*
|
|
* 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
|
|
{
|
|
/**
|
|
* 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
|
|
{
|
|
return Cache::get($this->key($host), $this->empty());
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
public function collect(Host $host): void
|
|
{
|
|
try {
|
|
$rows = $this->pve->forHost($host)->nodeRrdData($host->node ?? 'pve', 'hour');
|
|
} 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;
|
|
}
|
|
|
|
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 = [];
|
|
$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,
|
|
'netin' => $netin,
|
|
'netout' => $netout,
|
|
'available' => true,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* A share of a whole, in percent — or null when either half is missing.
|
|
*
|
|
* Null rather than 0.0, and that is the whole care taken here: Proxmox
|
|
* simply omits the fields for a sample it has no data for, and charting
|
|
* that omission as zero draws a quiet hour the host never had. The same
|
|
* distinction `instance_metrics` makes for its nullable columns, for the
|
|
* same reason.
|
|
*/
|
|
private function percent(mixed $part, mixed $whole): ?float
|
|
{
|
|
if (! is_numeric($part) || ! is_numeric($whole) || (float) $whole <= 0.0) {
|
|
return null;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|