From f3fdee90da04454cb3c9ec9d886c4806359793ef Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 16 Sep 2026 08:43:37 +0000 Subject: [PATCH] feat(metrics): add disk/network/load charts to the per-server metrics page --- app/Livewire/Server/Charts.php | 22 ++++++ app/Traits/HasMetrics.php | 70 ++++++++++++++++++- .../views/livewire/server/charts.blade.php | 57 ++++++++++++++- tests/Unit/Metrics/HasMetricsMappingTest.php | 61 ++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Metrics/HasMetricsMappingTest.php diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php index 567034c801..654f0b9686 100644 --- a/app/Livewire/Server/Charts.php +++ b/app/Livewire/Server/Charts.php @@ -100,15 +100,37 @@ class Charts extends Component try { $cpuMetrics = $this->server->getCpuMetrics($this->interval); $memoryMetrics = $this->server->getMemoryMetrics($this->interval); + // Disk/network/load arrived in a later Sentinel; fetch each optionally so an + // older Sentinel (which 404s them) still shows CPU and memory. + $diskMetrics = $this->optionalMetric(fn () => $this->server->getDiskMetrics($this->interval)); + $loadMetrics = $this->optionalMetric(fn () => $this->server->getLoadMetrics($this->interval)); + $networkMetrics = $this->optionalMetric(fn () => $this->server->getNetworkMetrics($this->interval)) ?? []; $this->dispatch("refreshChartData-{$this->chartId}-metrics", [ 'cpuSeries' => $cpuMetrics, 'memorySeries' => $memoryMetrics, + 'diskSeries' => $diskMetrics, + 'loadSeries' => $loadMetrics, + 'networkRxSeries' => $networkMetrics['rx'] ?? [], + 'networkTxSeries' => $networkMetrics['tx'] ?? [], ]); } catch (\Throwable $e) { return handleError($e, $this); } } + /** + * Fetch a metric that may not exist on an older Sentinel; a failure yields null + * rather than breaking the whole chart refresh. + */ + private function optionalMetric(callable $fetch): mixed + { + try { + return $fetch(); + } catch (\Throwable) { + return null; + } + } + public function setInterval() { if ($this->interval <= 10) { diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php index 712f09a48b..0cba5af8ab 100644 --- a/app/Traits/HasMetrics.php +++ b/app/Traits/HasMetrics.php @@ -27,7 +27,61 @@ trait HasMetrics return convertContainerMemoryBytesToMegabytes($metrics); } + /** + * Root filesystem usage percentage over time. Disk history interleaves every + * mount, so we keep only the root mount before mapping. + */ + public function getDiskMetrics(int $mins = 5): ?array + { + $rows = $this->fetchMetricRows('disk', $mins); + if ($rows === null) { + return null; + } + + $root = collect($rows)->filter(fn ($r) => ($r['mount'] ?? null) === '/')->values()->all(); + + return $this->mapRows($root ?: $rows, 'usedPercent', $mins); + } + + /** + * Host load average (1 minute) over time. + */ + public function getLoadMetrics(int $mins = 5): ?array + { + return $this->getMetrics('load', $mins, 'load1'); + } + + /** + * Network throughput over time, as separate receive/transmit byte-rate series. + * + * @return array{rx: ?array, tx: ?array}|null + */ + public function getNetworkMetrics(int $mins = 5): ?array + { + $rows = $this->fetchMetricRows('network', $mins); + if ($rows === null) { + return null; + } + + return [ + 'rx' => $this->mapRows($rows, 'rxBytesPerSec', $mins), + 'tx' => $this->mapRows($rows, 'txBytesPerSec', $mins), + ]; + } + private function getMetrics(string $type, int $mins, string $valueField): ?array + { + return $this->mapRows($this->fetchMetricRows($type, $mins), $valueField, $mins); + } + + /** + * Fetch and decode the raw Sentinel history rows for a metric type, or null when + * metrics are disabled. Throws on a Sentinel error response (caller decides whether + * to treat that as fatal or optional). + * + * @return array>|null + */ + protected function fetchMetricRows(string $type, int $mins): ?array { $server = $this->getMetricsServer(); if (! $server->isMetricsEnabled()) { @@ -63,7 +117,21 @@ trait HasMetrics throw new \Exception($error); } - $metrics = collect(json_decode($response, true))->map(function ($metric) use ($valueField) { + return json_decode($response, true) ?: []; + } + + /** + * Map raw rows to [timestampMs, value] pairs, downsampling long ranges. + * + * @param array>|null $rows + */ + private function mapRows(?array $rows, string $valueField, int $mins): ?array + { + if ($rows === null) { + return null; + } + + $metrics = collect($rows)->map(function ($metric) use ($valueField) { return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)]; })->toArray(); diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 442fc1979f..3592a1528d 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -69,6 +69,21 @@
+ +
+
+ + +
+
+ + +
+
+ @script diff --git a/tests/Unit/Metrics/HasMetricsMappingTest.php b/tests/Unit/Metrics/HasMetricsMappingTest.php new file mode 100644 index 0000000000..aff3dd557c --- /dev/null +++ b/tests/Unit/Metrics/HasMetricsMappingTest.php @@ -0,0 +1,61 @@ + type => rows */ + public array $rowsByType = []; + + protected function fetchMetricRows(string $type, int $mins): ?array + { + return $this->rowsByType[$type] ?? null; + } +} + +it('keeps only the root mount for disk metrics', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['disk'] = [ + ['time' => 1000, 'mount' => '/boot', 'usedPercent' => 90.0], + ['time' => 1000, 'mount' => '/', 'usedPercent' => 40.0], + ['time' => 2000, 'mount' => '/', 'usedPercent' => 42.0], + ]; + + expect($d->getDiskMetrics(5))->toBe([[1000, 40.0], [2000, 42.0]]); +}); + +it('splits network metrics into rx and tx series', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['network'] = [ + ['time' => 1000, 'rxBytesPerSec' => 100.0, 'txBytesPerSec' => 10.0], + ['time' => 2000, 'rxBytesPerSec' => 200.0, 'txBytesPerSec' => 20.0], + ]; + + expect($d->getNetworkMetrics(5))->toBe([ + 'rx' => [[1000, 100.0], [2000, 200.0]], + 'tx' => [[1000, 10.0], [2000, 20.0]], + ]); +}); + +it('maps the one minute load average', function () { + $d = new MetricsMappingDouble; + $d->rowsByType['load'] = [ + ['time' => 1000, 'load1' => 0.5, 'load5' => 1.0, 'load15' => 1.5], + ]; + + expect($d->getLoadMetrics(5))->toBe([[1000, 0.5]]); +}); + +it('returns null for a metric when metrics are unavailable', function () { + $d = new MetricsMappingDouble; // rowsByType empty -> fetch returns null + + expect($d->getDiskMetrics(5))->toBeNull(); + expect($d->getNetworkMetrics(5))->toBeNull(); + expect($d->getLoadMetrics(5))->toBeNull(); +});